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:
@@ -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) │
|
||||
└─────────────┘ └────────┬────────┘ └──────────────┘
|
||||
│
|
||||
@@ -20,14 +20,15 @@ A self-service Platform-as-a-Service (PaaS) that lets developers deploy **Node.j
|
||||
|
||||
| Layer | Technology |
|
||||
| ------------ | ------------------------------------------------------- |
|
||||
| Frontend | Next.js 14, Tailwind CSS, React Query, Zustand |
|
||||
| Backend API | NestJS 10, TypeORM, Passport JWT, Bull (Redis) |
|
||||
| Frontend | Next.js 16, Tailwind CSS v4, React Query, Zustand |
|
||||
| Backend API | NestJS 11, TypeORM, Passport JWT, Bull (Redis) |
|
||||
| Build Engine | Kaniko (in-cluster, daemon-less Docker builds) |
|
||||
| Deployment | Helm v3 charts, @kubernetes/client-node |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Queue | Redis 7 + BullMQ |
|
||||
|
||||
> 📖 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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
|
||||
# ---- Stage 1: Build ----
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -10,7 +10,7 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: Production ----
|
||||
FROM node:20-alpine AS production
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
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 \
|
||||
|
||||
Generated
+3504
-3645
File diff suppressed because it is too large
Load Diff
+33
-34
@@ -21,53 +21,52 @@
|
||||
"seed": "ts-node -r tsconfig-paths/register src/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kubernetes/client-node": "^0.21.0",
|
||||
"@nestjs/bull": "^10.1.0",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/swagger": "^7.2.0",
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
"bcrypt": "^5.1.1",
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@nestjs/bull": "^11.0.4",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.26",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.26",
|
||||
"@nestjs/swagger": "^11.4.4",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bull": "^4.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^7.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"helmet": "^8.2.0",
|
||||
"js-yaml": "^4.2.0",
|
||||
"multer": "^2.1.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.11.0",
|
||||
"pg": "^8.21.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.19",
|
||||
"uuid": "^9.0.0"
|
||||
"rxjs": "^7.8.2",
|
||||
"typeorm": "^1.0.0",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.1.26",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/multer": "^1.4.11",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.0",
|
||||
"@typescript-eslint/parser": "^6.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.2.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^9.0.0",
|
||||
"jest": "^30.4.2",
|
||||
"prettier": "^3.8.4",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.3.3"
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
|
||||
@@ -312,7 +312,7 @@ export class AccessService implements OnModuleInit {
|
||||
async revokeGrant(grantId: string, userId: string | undefined, system = false): Promise<void> {
|
||||
const grant = await this.grantsRepo.findOne({
|
||||
where: { id: grantId },
|
||||
relations: ['application'],
|
||||
relations: { application: true },
|
||||
});
|
||||
if (!grant) throw new NotFoundException('Access grant not found');
|
||||
if (!system && userId !== undefined && grant.userId !== userId) {
|
||||
|
||||
@@ -79,7 +79,7 @@ export class ApplicationMigrationsService {
|
||||
async list(applicationId?: string): Promise<ApplicationMigrationJob[]> {
|
||||
return this.jobsRepository.find({
|
||||
where: applicationId ? { applicationId } : {},
|
||||
relations: ['application', 'sourceCluster', 'targetCluster'],
|
||||
relations: { application: true, sourceCluster: true, targetCluster: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
@@ -88,7 +88,7 @@ export class ApplicationMigrationsService {
|
||||
async findOne(id: string): Promise<ApplicationMigrationJob> {
|
||||
const job = await this.jobsRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['application', 'sourceCluster', 'targetCluster'],
|
||||
relations: { application: true, sourceCluster: true, targetCluster: true },
|
||||
});
|
||||
if (!job) {
|
||||
throw new NotFoundException('Migration job not found');
|
||||
|
||||
@@ -209,7 +209,7 @@ export class ApplicationsService {
|
||||
|
||||
const app = await this.appsRepository.findOne({
|
||||
where,
|
||||
relations: ['deployments'],
|
||||
relations: { deployments: true },
|
||||
});
|
||||
|
||||
if (!app) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.get('jwt.secret'),
|
||||
secretOrKey: configService.getOrThrow<string>('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@ export class BillingService {
|
||||
const wallet = await this.getOrCreateWallet(userId);
|
||||
return this.txRepo.find({
|
||||
where: { walletId: wallet.id },
|
||||
relations: ['invoice'],
|
||||
relations: { invoice: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
@@ -198,7 +198,7 @@ export class BillingService {
|
||||
|
||||
// Admin: get all wallets
|
||||
async getAllWallets(): Promise<Wallet[]> {
|
||||
return this.walletRepo.find({ relations: ['user'], order: { balance: 'DESC' } });
|
||||
return this.walletRepo.find({ relations: { user: true }, order: { balance: 'DESC' } });
|
||||
}
|
||||
|
||||
// ─── Invoices ─────────────────────────────────────────────────────
|
||||
@@ -300,7 +300,7 @@ export class BillingService {
|
||||
async getInvoiceForUser(invoiceId: string, user: { id: string; role?: UserRole }): Promise<Invoice> {
|
||||
const invoice = await this.invoiceRepo.findOne({
|
||||
where: { id: invoiceId },
|
||||
relations: ['user', 'application', 'lines', 'transactions'],
|
||||
relations: { user: true, application: true, lines: true, transactions: true },
|
||||
order: { lines: { createdAt: 'ASC' }, transactions: { createdAt: 'DESC' } },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException('Invoice not found');
|
||||
|
||||
+273
-201
@@ -49,8 +49,7 @@ export class BuildService {
|
||||
* 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.
|
||||
*/
|
||||
private readonly kanikoImage =
|
||||
process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
|
||||
private readonly kanikoImage = process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
|
||||
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
@@ -77,7 +76,11 @@ export class BuildService {
|
||||
if (!session) return;
|
||||
session.processes.push(proc);
|
||||
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);
|
||||
if (!session) return;
|
||||
if (session.socket) {
|
||||
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
session.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
session.socket = socket;
|
||||
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> {
|
||||
const session = this.activeBuilds.get(deploymentId);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -114,10 +129,18 @@ export class BuildService {
|
||||
this.logger.log(`Cancelling build for deployment ${deploymentId}`);
|
||||
|
||||
if (session.socket) {
|
||||
try { session.socket.destroy(); } catch { /* ignore */ }
|
||||
try {
|
||||
session.socket.destroy();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
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;
|
||||
@@ -125,29 +148,56 @@ export class BuildService {
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
if (helperPodName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
gracePeriodSeconds: 0,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (buildPodName && batchApi) {
|
||||
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) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, namespace).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedPersistentVolumeClaim({
|
||||
name: sourcePvcName,
|
||||
namespace,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (buildPodName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, namespace).catch(() => undefined),
|
||||
coreApi
|
||||
.deleteNamespacedConfigMap({
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
namespace,
|
||||
})
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
await Promise.all(cleanup);
|
||||
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
|
||||
}
|
||||
|
||||
this.setProgress(deploymentId, { phase: 'cancelled', percent: 0, message: 'Cancelled by user' });
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'cancelled',
|
||||
percent: 0,
|
||||
message: 'Cancelled by user',
|
||||
});
|
||||
this.activeBuilds.delete(deploymentId);
|
||||
}
|
||||
|
||||
@@ -156,9 +206,7 @@ export class BuildService {
|
||||
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
|
||||
const prefix = `build-${app.name}-`;
|
||||
|
||||
const cluster = app.clusterId
|
||||
? await this.clustersService.findOne(app.clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -168,34 +216,60 @@ export class BuildService {
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
|
||||
const [pods, pvcs, jobs, configMaps] = await Promise.all([
|
||||
coreApi.listNamespacedPod(buildNamespace),
|
||||
coreApi.listNamespacedPersistentVolumeClaim(buildNamespace),
|
||||
batchApi.listNamespacedJob(buildNamespace),
|
||||
coreApi.listNamespacedConfigMap(buildNamespace),
|
||||
coreApi.listNamespacedPod({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedPersistentVolumeClaim({
|
||||
namespace: 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 || '';
|
||||
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 || '';
|
||||
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 || '';
|
||||
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 || '';
|
||||
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
|
||||
const cluster = app.clusterId
|
||||
? await this.clustersService.findOne(app.clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -253,7 +325,11 @@ export class BuildService {
|
||||
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
|
||||
|
||||
if (deploymentId) {
|
||||
this.updateBuildSession(deploymentId, { coreApi, batchApi, namespace: buildNamespace });
|
||||
this.updateBuildSession(deploymentId, {
|
||||
coreApi,
|
||||
batchApi,
|
||||
namespace: buildNamespace,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure the build namespace exists
|
||||
@@ -289,9 +365,7 @@ export class BuildService {
|
||||
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
|
||||
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
|
||||
|
||||
await this.uploadSourceViaPVC(
|
||||
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId,
|
||||
);
|
||||
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId);
|
||||
}
|
||||
|
||||
// Build the Kaniko Job spec
|
||||
@@ -339,7 +413,10 @@ export class BuildService {
|
||||
name: 'unzip-source',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
apk add --no-cache unzip tar gzip &&
|
||||
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
|
||||
mkdir -p /tmp/extract &&
|
||||
@@ -368,10 +445,15 @@ export class BuildService {
|
||||
rm -rf /tmp/extract &&
|
||||
echo "--- Final workspace contents ---" &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ 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' },
|
||||
],
|
||||
});
|
||||
@@ -398,13 +480,17 @@ export class BuildService {
|
||||
name: 'git-clone',
|
||||
image: 'alpine/git:2.43.0',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
|
||||
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Workspace contents:" &&
|
||||
ls -la /workspace-out/source/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||
@@ -426,12 +512,16 @@ export class BuildService {
|
||||
name: 'prepare-workspace',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', `
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
mkdir -p /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Prepared empty workspace for fresh install" &&
|
||||
ls -la /workspace-out/
|
||||
`],
|
||||
`,
|
||||
],
|
||||
volumeMounts: [
|
||||
{ name: 'workspace', mountPath: '/workspace-out' },
|
||||
{ name: 'dockerfile', mountPath: '/dockerfile' },
|
||||
@@ -475,15 +565,25 @@ export class BuildService {
|
||||
|
||||
try {
|
||||
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`);
|
||||
|
||||
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`);
|
||||
|
||||
// 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);
|
||||
|
||||
// Capture build logs on success
|
||||
@@ -513,7 +613,10 @@ export class BuildService {
|
||||
// Clean up build resources
|
||||
if (sourcePvcName) {
|
||||
try {
|
||||
await coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, buildNamespace!);
|
||||
await coreApi.deleteNamespacedPersistentVolumeClaim({
|
||||
name: sourcePvcName,
|
||||
namespace: buildNamespace!,
|
||||
});
|
||||
this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`);
|
||||
@@ -521,7 +624,10 @@ export class BuildService {
|
||||
}
|
||||
// Clean up Dockerfile ConfigMap
|
||||
try {
|
||||
await coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, buildNamespace!);
|
||||
await coreApi.deleteNamespacedConfigMap({
|
||||
name: `${buildPodName}-dockerfile`,
|
||||
namespace: buildNamespace!,
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
||||
}
|
||||
@@ -533,38 +639,30 @@ export class BuildService {
|
||||
* Upload a local file to the helper pod using kubectl cp with progress tracking.
|
||||
* kubectl cp uses tar over the k8s exec API — reliable for any file size.
|
||||
*/
|
||||
private streamFileToHelperPod(
|
||||
kubeconfig: string,
|
||||
namespace: string,
|
||||
podName: string,
|
||||
filePath: string,
|
||||
fileSize: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private streamFileToHelperPod(kubeconfig: string, namespace: string, podName: string, filePath: string, fileSize: number, deploymentId?: string): Promise<void> {
|
||||
const maxAttempts = 3;
|
||||
|
||||
const runOnce = () => new Promise<void>((resolve, reject) => {
|
||||
const runOnce = () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
|
||||
const kubectl = spawn('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'cp', filePath, `${namespace}/${podName}:/data/source.zip`,
|
||||
'-c', 'helper',
|
||||
'--retries', '3',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
const kubectl = spawn('kubectl', ['--kubeconfig', kubeconfig, 'cp', filePath, `${namespace}/${podName}:/data/source.zip`, '-c', 'helper', '--retries', '3'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
this.registerProcess(deploymentId, kubectl);
|
||||
|
||||
let stderr = '';
|
||||
kubectl.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
kubectl.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
// Track progress by polling remote file size
|
||||
let progressTimer: NodeJS.Timeout | undefined;
|
||||
const pollProgress = () => {
|
||||
execFileAsync('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'exec', '-n', namespace, podName, '-c', 'helper', '--',
|
||||
'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0',
|
||||
], { timeout: 10_000 }).then(({ stdout }) => {
|
||||
execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0'], {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.then(({ stdout }) => {
|
||||
const remoteSize = parseInt(stdout.trim(), 10) || 0;
|
||||
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
|
||||
this.setProgress(deploymentId, {
|
||||
@@ -574,7 +672,10 @@ export class BuildService {
|
||||
totalBytes: fileSize,
|
||||
message: `Uploading to cluster... ${percent}%`,
|
||||
});
|
||||
}).catch(() => { /* polling failure is non-fatal */ });
|
||||
})
|
||||
.catch(() => {
|
||||
/* polling failure is non-fatal */
|
||||
});
|
||||
};
|
||||
progressTimer = setInterval(pollProgress, 3000);
|
||||
pollProgress();
|
||||
@@ -597,11 +698,9 @@ export class BuildService {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
if (attempt > 1) {
|
||||
this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`);
|
||||
await execFileAsync('kubectl', [
|
||||
'--kubeconfig', kubeconfig,
|
||||
'exec', '-n', namespace, podName, '-c', 'helper', '--',
|
||||
'rm', '-f', '/data/source.zip',
|
||||
], { timeout: 15_000 }).catch(() => undefined);
|
||||
await execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'rm', '-f', '/data/source.zip'], { timeout: 15_000 }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
this.setProgress(deploymentId, {
|
||||
phase: 'uploading',
|
||||
percent: 0,
|
||||
@@ -625,15 +724,7 @@ export class BuildService {
|
||||
* Upload source zip to K8s via PVC + helper pod.
|
||||
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
|
||||
*/
|
||||
private async uploadSourceViaPVC(
|
||||
kc: k8s.KubeConfig,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
namespace: string,
|
||||
pvcName: string,
|
||||
zipPath: string,
|
||||
sizeGi: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private async uploadSourceViaPVC(kc: k8s.KubeConfig, coreApi: k8s.CoreV1Api, namespace: string, pvcName: string, zipPath: string, sizeGi: number, deploymentId?: string): Promise<void> {
|
||||
const t0 = Date.now();
|
||||
const helperPodName = `${pvcName}-helper`;
|
||||
const zipSize = fs.statSync(zipPath).size;
|
||||
@@ -645,7 +736,9 @@ export class BuildService {
|
||||
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
|
||||
|
||||
// 1. Create PVC
|
||||
await coreApi.createNamespacedPersistentVolumeClaim(namespace, {
|
||||
await coreApi.createNamespacedPersistentVolumeClaim({
|
||||
namespace,
|
||||
body: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'PersistentVolumeClaim',
|
||||
metadata: { name: pvcName, namespace },
|
||||
@@ -653,6 +746,7 @@ export class BuildService {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: `${sizeGi}Gi` } },
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`);
|
||||
|
||||
@@ -664,7 +758,8 @@ export class BuildService {
|
||||
kind: 'Pod',
|
||||
metadata: { name: helperPodName, namespace },
|
||||
spec: {
|
||||
containers: [{
|
||||
containers: [
|
||||
{
|
||||
name: 'helper',
|
||||
image: 'alpine:3.19',
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
@@ -674,29 +769,35 @@ export class BuildService {
|
||||
requests: { cpu: '100m', memory: '128Mi' },
|
||||
limits: { cpu: '500m', memory: '256Mi' },
|
||||
},
|
||||
}],
|
||||
volumes: [{
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: 'source',
|
||||
persistentVolumeClaim: { claimName: pvcName },
|
||||
}],
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
};
|
||||
|
||||
await coreApi.createNamespacedPod(namespace, helperPod);
|
||||
await coreApi.createNamespacedPod({ namespace, body: helperPod });
|
||||
|
||||
// 3. Wait for helper pod to be Running
|
||||
const podTimeout = 120_000; // 2 minutes
|
||||
const podStart = Date.now();
|
||||
while (Date.now() - podStart < podTimeout) {
|
||||
this.throwIfCancelled(deploymentId);
|
||||
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
|
||||
const phase = pod.body.status?.phase;
|
||||
const pod = await coreApi.readNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
});
|
||||
const phase = pod.status?.phase;
|
||||
if (phase === 'Running') break;
|
||||
if (phase === 'Failed' || phase === 'Unknown') {
|
||||
throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
if (Date.now() - podStart >= podTimeout) {
|
||||
throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`);
|
||||
@@ -719,9 +820,7 @@ export class BuildService {
|
||||
message: 'Uploading source to cluster...',
|
||||
});
|
||||
|
||||
await this.streamFileToHelperPod(
|
||||
tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId,
|
||||
);
|
||||
await this.streamFileToHelperPod(tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId);
|
||||
|
||||
this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
|
||||
this.setProgress(deploymentId, {
|
||||
@@ -733,11 +832,11 @@ export class BuildService {
|
||||
});
|
||||
|
||||
// 5b. Verify the file was written correctly (exact size)
|
||||
const { stdout: sizeStr } = await execFileAsync('kubectl', [
|
||||
'--kubeconfig', tmpKubeconfig,
|
||||
'exec', '-n', namespace, helperPodName, '-c', 'helper',
|
||||
'--', 'sh', '-c', 'wc -c < /data/source.zip',
|
||||
], { timeout: 30_000 });
|
||||
const { stdout: sizeStr } = await execFileAsync(
|
||||
'kubectl',
|
||||
['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip'],
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
const remoteSize = parseInt(sizeStr.trim(), 10);
|
||||
if (isNaN(remoteSize) || remoteSize !== zipSize) {
|
||||
@@ -750,24 +849,30 @@ export class BuildService {
|
||||
this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`);
|
||||
} finally {
|
||||
// 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
|
||||
// (PVC is ReadWriteOnce — if the pod is still terminating when the
|
||||
// build Job starts, Kaniko can't mount the PVC → stuck in Pending)
|
||||
try {
|
||||
await coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0);
|
||||
await coreApi.deleteNamespacedPod({
|
||||
name: helperPodName,
|
||||
namespace,
|
||||
gracePeriodSeconds: 0,
|
||||
});
|
||||
this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`);
|
||||
|
||||
const delTimeout = 60_000;
|
||||
const delStart = Date.now();
|
||||
while (Date.now() - delStart < delTimeout) {
|
||||
try {
|
||||
await coreApi.readNamespacedPod(helperPodName, namespace);
|
||||
await coreApi.readNamespacedPod({ name: helperPodName, namespace });
|
||||
// Pod still exists — wait
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
} 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`);
|
||||
break;
|
||||
}
|
||||
@@ -790,12 +895,12 @@ export class BuildService {
|
||||
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
|
||||
// 1. Ensure namespace
|
||||
try {
|
||||
await coreApi.readNamespace(namespace);
|
||||
await coreApi.readNamespace({ name: namespace });
|
||||
} 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`);
|
||||
await coreApi.createNamespace({
|
||||
metadata: { name: namespace },
|
||||
body: { metadata: { name: namespace } },
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
@@ -805,12 +910,13 @@ export class BuildService {
|
||||
// 2. Ensure service account for Kaniko
|
||||
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
|
||||
try {
|
||||
await coreApi.readNamespacedServiceAccount(saName, namespace);
|
||||
await coreApi.readNamespacedServiceAccount({ name: saName, namespace });
|
||||
} 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`);
|
||||
await coreApi.createNamespacedServiceAccount(namespace, {
|
||||
metadata: { name: saName, namespace },
|
||||
await coreApi.createNamespacedServiceAccount({
|
||||
namespace,
|
||||
body: { metadata: { name: saName, namespace } },
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
@@ -820,16 +926,22 @@ export class BuildService {
|
||||
// 3. Ensure registry-credentials secret (docker config for Kaniko to push)
|
||||
const registrySecretName = 'registry-credentials';
|
||||
try {
|
||||
await coreApi.readNamespacedSecret(registrySecretName, namespace);
|
||||
await coreApi.readNamespacedSecret({
|
||||
name: registrySecretName,
|
||||
namespace,
|
||||
});
|
||||
} 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`);
|
||||
await coreApi.createNamespacedSecret(namespace, {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace,
|
||||
body: {
|
||||
metadata: { name: registrySecretName, namespace },
|
||||
type: 'kubernetes.io/dockerconfigjson',
|
||||
data: {
|
||||
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
@@ -870,7 +982,7 @@ export class BuildService {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return app.runtime;
|
||||
}
|
||||
@@ -895,9 +1007,7 @@ export class BuildService {
|
||||
}
|
||||
|
||||
if (detected && detected !== app.runtime) {
|
||||
this.logger.warn(
|
||||
`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`,
|
||||
);
|
||||
this.logger.warn(`Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`);
|
||||
return detected;
|
||||
}
|
||||
|
||||
@@ -1094,7 +1204,9 @@ RUN a2enmod rewrite
|
||||
# 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
|
||||
|
||||
${hasUploadedCode ? `# Copy user's custom WordPress files
|
||||
${
|
||||
hasUploadedCode
|
||||
? `# Copy user's custom WordPress files
|
||||
COPY . /tmp/user-content
|
||||
|
||||
# Auto-detect: full public_html root (has wp-admin) vs wp-content only
|
||||
@@ -1176,14 +1288,20 @@ RUN { \\
|
||||
echo ''; \\
|
||||
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
|
||||
} > /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
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
|
||||
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)) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [
|
||||
err?.code,
|
||||
err?.message,
|
||||
err?.body?.message,
|
||||
err?.cause?.code,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const haystack = [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(
|
||||
haystack,
|
||||
);
|
||||
}
|
||||
|
||||
private async waitForJobCompletion(
|
||||
batchApi: k8s.BatchV1Api,
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
timeoutSeconds: number,
|
||||
deploymentId?: string,
|
||||
): Promise<void> {
|
||||
private async waitForJobCompletion(batchApi: k8s.BatchV1Api, coreApi: k8s.CoreV1Api, jobName: string, namespace: string, timeoutSeconds: number, deploymentId?: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = timeoutSeconds * 1000;
|
||||
let lastLoggedStatus = '';
|
||||
@@ -1513,9 +1617,9 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
|
||||
message: 'Building Docker image...',
|
||||
});
|
||||
// ── Check Job status (with retry for transient connection errors) ──
|
||||
let job: { body: k8s.V1Job };
|
||||
let job: k8s.V1Job;
|
||||
try {
|
||||
job = await batchApi.readNamespacedJob(jobName, namespace);
|
||||
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
|
||||
} catch (pollErr: any) {
|
||||
// The Kaniko job keeps running independently of these status polls.
|
||||
// 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)) {
|
||||
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
|
||||
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;
|
||||
}
|
||||
throw pollErr;
|
||||
}
|
||||
const status = job.body.status;
|
||||
const status = job.status;
|
||||
|
||||
if (status?.succeeded && status.succeeded > 0) {
|
||||
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)
|
||||
const failedCondition = (status?.conditions || []).find(
|
||||
(c) => c.type === 'Failed' && c.status === 'True',
|
||||
);
|
||||
const failedCondition = (status?.conditions || []).find((c) => c.type === 'Failed' && c.status === 'True');
|
||||
if (failedCondition) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (failedCount > backoffLimit) {
|
||||
// Double-check: are there still active pods?
|
||||
const activePods = (status as any)?.active ?? 0;
|
||||
if (activePods === 0) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(
|
||||
`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`,
|
||||
);
|
||||
throw new Error(`Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log intermediate pod failures (retries still available)
|
||||
if (failedCount > 0) {
|
||||
this.logger.warn(
|
||||
`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`,
|
||||
);
|
||||
this.logger.warn(`Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`);
|
||||
}
|
||||
|
||||
// ── Check Pod status for early failure detection ──
|
||||
try {
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
namespace, undefined, undefined, undefined, undefined,
|
||||
`job-name=${jobName}`,
|
||||
);
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
|
||||
for (const pod of pods.body.items) {
|
||||
for (const pod of pods.items) {
|
||||
const podName = pod.metadata?.name || 'unknown';
|
||||
const phase = pod.status?.phase;
|
||||
|
||||
// Check all container statuses (init + regular) for stuck states
|
||||
const allStatuses = [
|
||||
...(pod.status?.initContainerStatuses || []),
|
||||
...(pod.status?.containerStatuses || []),
|
||||
];
|
||||
const allStatuses = [...(pod.status?.initContainerStatuses || []), ...(pod.status?.containerStatuses || [])];
|
||||
|
||||
for (const cs of allStatuses) {
|
||||
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 || '';
|
||||
|
||||
// These are unrecoverable — fail fast instead of waiting 10 minutes
|
||||
const fatalReasons = [
|
||||
'ErrImagePull', 'ImagePullBackOff',
|
||||
'CreateContainerConfigError', 'InvalidImageName',
|
||||
'CrashLoopBackOff',
|
||||
];
|
||||
const fatalReasons = ['ErrImagePull', 'ImagePullBackOff', 'CreateContainerConfigError', 'InvalidImageName', 'CrashLoopBackOff'];
|
||||
|
||||
if (fatalReasons.includes(reason)) {
|
||||
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
|
||||
throw new Error(
|
||||
`Build pod ${podName} stuck: ${reason} — ${msg}\nLogs:\n${logs}`,
|
||||
);
|
||||
throw new Error(`Build pod ${podName} stuck: ${reason} — ${msg}\nLogs:\n${logs}`);
|
||||
}
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
private async getBuildLogs(
|
||||
coreApi: k8s.CoreV1Api,
|
||||
jobName: string,
|
||||
namespace: string,
|
||||
): Promise<string> {
|
||||
private async getBuildLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
|
||||
try {
|
||||
const pods = await coreApi.listNamespacedPod(
|
||||
const pods = await coreApi.listNamespacedPod({
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`job-name=${jobName}`,
|
||||
);
|
||||
labelSelector: `job-name=${jobName}`,
|
||||
});
|
||||
|
||||
if (pods.body.items.length === 0) {
|
||||
if (pods.items.length === 0) {
|
||||
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.';
|
||||
|
||||
// Get logs from all containers (init + kaniko)
|
||||
let allLogs = '';
|
||||
const containers = [
|
||||
...(pods.body.items[0].spec?.initContainers || []),
|
||||
...(pods.body.items[0].spec?.containers || []),
|
||||
];
|
||||
const containers = [...(pods.items[0].spec?.initContainers || []), ...(pods.items[0].spec?.containers || [])];
|
||||
|
||||
for (const container of containers) {
|
||||
try {
|
||||
const logResponse = await coreApi.readNamespacedPodLog(
|
||||
podName,
|
||||
const logResponse = await coreApi.readNamespacedPodLog({
|
||||
name: podName,
|
||||
namespace,
|
||||
container.name,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
500,
|
||||
);
|
||||
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
|
||||
container: container.name,
|
||||
tailLines: 500,
|
||||
});
|
||||
allLogs += `\n--- ${container.name} ---\n${logResponse}`;
|
||||
} catch {
|
||||
allLogs += `\n--- ${container.name} --- (no logs available)`;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger, BadRequestException, Inject, forwardRef } from '@nestjs/common';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
import { ClustersService } from './clusters.service';
|
||||
import { HelmService } from '../kubernetes/helm.service';
|
||||
import { ElasticsearchService } from '../kubernetes/elasticsearch.service';
|
||||
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
|
||||
import {
|
||||
ClusterToolDefinition,
|
||||
ClusterToolId,
|
||||
ClusterToolState,
|
||||
ClusterToolStatus,
|
||||
} from './cluster-tools.types';
|
||||
import { ClusterToolDefinition, ClusterToolId, ClusterToolState, ClusterToolStatus } from './cluster-tools.types';
|
||||
|
||||
const CERT_MANAGER_RELEASE = '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';
|
||||
// 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.
|
||||
const DEFAULT_INGRESS_CLASS = (process.env.INGRESS_CLASS || 'traefik')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const DEFAULT_INGRESS_CLASS = (process.env.INGRESS_CLASS || 'traefik').trim().toLowerCase();
|
||||
|
||||
const ISSUER_GROUP = 'cert-manager.io';
|
||||
const ISSUER_VERSION = 'v1';
|
||||
@@ -44,8 +31,7 @@ export class ClusterToolsService {
|
||||
{
|
||||
id: 'cert-manager',
|
||||
name: 'cert-manager',
|
||||
description:
|
||||
'Automated TLS certificate management. Required before creating a ClusterIssuer.',
|
||||
description: 'Automated TLS certificate management. Required before creating a ClusterIssuer.',
|
||||
category: 'Certificates',
|
||||
dependencies: [],
|
||||
installFields: [],
|
||||
@@ -53,8 +39,7 @@ export class ClusterToolsService {
|
||||
{
|
||||
id: 'cluster-issuer',
|
||||
name: "ClusterIssuer (Let's Encrypt)",
|
||||
description:
|
||||
'Cluster-wide ACME issuer (letsencrypt-prod) using HTTP01 challenge. Requires cert-manager.',
|
||||
description: 'Cluster-wide ACME issuer (letsencrypt-prod) using HTTP01 challenge. Requires cert-manager.',
|
||||
category: 'Certificates',
|
||||
dependencies: ['cert-manager'],
|
||||
installFields: [
|
||||
@@ -71,8 +56,7 @@ export class ClusterToolsService {
|
||||
{
|
||||
id: 'central-elastic',
|
||||
name: 'Central Elasticsearch + Kibana',
|
||||
description:
|
||||
'Shared logging stack powering the unified Logs page. Installed via Helm in the "logging" namespace.',
|
||||
description: 'Shared logging stack powering the unified Logs page. Installed via Helm in the "logging" namespace.',
|
||||
category: 'Logging',
|
||||
dependencies: [],
|
||||
installFields: [],
|
||||
@@ -96,11 +80,7 @@ export class ClusterToolsService {
|
||||
return Promise.all(
|
||||
this.catalog.map(async (def) => {
|
||||
try {
|
||||
const { status, message, details } = await this.statusOf(
|
||||
def.id,
|
||||
clusterId,
|
||||
kubeconfig,
|
||||
);
|
||||
const { status, message, details } = await this.statusOf(def.id, clusterId, kubeconfig);
|
||||
return { ...def, status, message, details };
|
||||
} catch (err: any) {
|
||||
return {
|
||||
@@ -113,11 +93,7 @@ export class ClusterToolsService {
|
||||
);
|
||||
}
|
||||
|
||||
async install(
|
||||
clusterId: string,
|
||||
toolId: ClusterToolId,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
async install(clusterId: string, toolId: ClusterToolId, params: Record<string, string> = {}): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
const def = this.requireTool(toolId);
|
||||
const kubeconfig = await this.getKubeconfig(clusterId);
|
||||
|
||||
@@ -125,9 +101,7 @@ export class ClusterToolsService {
|
||||
for (const depId of def.dependencies) {
|
||||
const dep = await this.statusOf(depId, clusterId, kubeconfig);
|
||||
if (dep.status !== 'installed') {
|
||||
throw new BadRequestException(
|
||||
`"${this.requireTool(depId).name}" must be installed before "${def.name}".`,
|
||||
);
|
||||
throw new BadRequestException(`"${this.requireTool(depId).name}" must be installed before "${def.name}".`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,10 +115,7 @@ export class ClusterToolsService {
|
||||
}
|
||||
}
|
||||
|
||||
async uninstall(
|
||||
clusterId: string,
|
||||
toolId: ClusterToolId,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
async uninstall(clusterId: string, toolId: ClusterToolId): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
this.requireTool(toolId);
|
||||
const kubeconfig = await this.getKubeconfig(clusterId);
|
||||
|
||||
@@ -160,9 +131,7 @@ export class ClusterToolsService {
|
||||
|
||||
// ── cert-manager ───────────────────────────────────────────────────
|
||||
|
||||
private async installCertManager(
|
||||
kubeconfig: string,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
private async installCertManager(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
await this.helmService.installRemoteChart({
|
||||
repoName: CERT_MANAGER_REPO_NAME,
|
||||
repoUrl: CERT_MANAGER_REPO_URL,
|
||||
@@ -176,28 +145,18 @@ export class ClusterToolsService {
|
||||
});
|
||||
return {
|
||||
status: 'installing',
|
||||
message:
|
||||
'cert-manager install started. Pods are starting — allow 1–2 minutes to become ready.',
|
||||
message: 'cert-manager install started. Pods are starting — allow 1–2 minutes to become ready.',
|
||||
};
|
||||
}
|
||||
|
||||
private async uninstallCertManager(
|
||||
kubeconfig: string,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
await this.helmService.uninstall(
|
||||
CERT_MANAGER_RELEASE,
|
||||
CERT_MANAGER_NAMESPACE,
|
||||
kubeconfig,
|
||||
);
|
||||
private async uninstallCertManager(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
await this.helmService.uninstall(CERT_MANAGER_RELEASE, CERT_MANAGER_NAMESPACE, kubeconfig);
|
||||
return { status: 'not_installed', message: 'cert-manager removed.' };
|
||||
}
|
||||
|
||||
// ── ClusterIssuer ──────────────────────────────────────────────────
|
||||
|
||||
private async installClusterIssuer(
|
||||
kubeconfig: string,
|
||||
params: Record<string, string>,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
private async installClusterIssuer(kubeconfig: string, params: Record<string, string>): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
const email = (params.email || '').trim();
|
||||
if (!email) {
|
||||
throw new BadRequestException('An ACME email is required for the ClusterIssuer.');
|
||||
@@ -220,33 +179,31 @@ export class ClusterToolsService {
|
||||
};
|
||||
|
||||
try {
|
||||
await api.getClusterCustomObject(
|
||||
ISSUER_GROUP,
|
||||
ISSUER_VERSION,
|
||||
ISSUER_PLURAL,
|
||||
CLUSTER_ISSUER_NAME,
|
||||
);
|
||||
await api.replaceClusterCustomObject(
|
||||
ISSUER_GROUP,
|
||||
ISSUER_VERSION,
|
||||
ISSUER_PLURAL,
|
||||
CLUSTER_ISSUER_NAME,
|
||||
await api.getClusterCustomObject({
|
||||
group: ISSUER_GROUP,
|
||||
version: ISSUER_VERSION,
|
||||
plural: ISSUER_PLURAL,
|
||||
name: CLUSTER_ISSUER_NAME,
|
||||
});
|
||||
await api.replaceClusterCustomObject({
|
||||
group: ISSUER_GROUP,
|
||||
version: ISSUER_VERSION,
|
||||
plural: ISSUER_PLURAL,
|
||||
name: CLUSTER_ISSUER_NAME,
|
||||
body,
|
||||
);
|
||||
});
|
||||
this.logger.log(`Updated ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
|
||||
} catch (err: any) {
|
||||
if (this.isNotFound(err)) {
|
||||
await api.createClusterCustomObject(
|
||||
ISSUER_GROUP,
|
||||
ISSUER_VERSION,
|
||||
ISSUER_PLURAL,
|
||||
await api.createClusterCustomObject({
|
||||
group: ISSUER_GROUP,
|
||||
version: ISSUER_VERSION,
|
||||
plural: ISSUER_PLURAL,
|
||||
body,
|
||||
);
|
||||
});
|
||||
this.logger.log(`Created ClusterIssuer "${CLUSTER_ISSUER_NAME}"`);
|
||||
} else if (this.isMissingCrd(err)) {
|
||||
throw new BadRequestException(
|
||||
'cert-manager CRDs are not available yet. Wait for cert-manager to finish installing, then retry.',
|
||||
);
|
||||
throw new BadRequestException('cert-manager CRDs are not available yet. Wait for cert-manager to finish installing, then retry.');
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
@@ -258,40 +215,35 @@ export class ClusterToolsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async uninstallClusterIssuer(
|
||||
kubeconfig: string,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
private async uninstallClusterIssuer(kubeconfig: string): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
const api = this.customObjectsApi(kubeconfig);
|
||||
try {
|
||||
await api.deleteClusterCustomObject(
|
||||
ISSUER_GROUP,
|
||||
ISSUER_VERSION,
|
||||
ISSUER_PLURAL,
|
||||
CLUSTER_ISSUER_NAME,
|
||||
);
|
||||
await api.deleteClusterCustomObject({
|
||||
group: ISSUER_GROUP,
|
||||
version: ISSUER_VERSION,
|
||||
plural: ISSUER_PLURAL,
|
||||
name: CLUSTER_ISSUER_NAME,
|
||||
});
|
||||
} catch (err: any) {
|
||||
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 ──────────────────────────────────────────
|
||||
|
||||
private async installCentralElastic(
|
||||
clusterId: string,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
private async installCentralElastic(clusterId: string): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
const result = await this.elasticsearchService.deploy(clusterId);
|
||||
return {
|
||||
status: result.deploying ? 'installing' : 'installed',
|
||||
message: result.deploying
|
||||
? 'Logging stack installed. Allow 2–5 minutes for Elasticsearch and Kibana to become ready.'
|
||||
: 'Elasticsearch and Kibana are ready.',
|
||||
message: result.deploying ? 'Logging stack installed. Allow 2–5 minutes for Elasticsearch and Kibana to become ready.' : 'Elasticsearch and Kibana are ready.',
|
||||
};
|
||||
}
|
||||
|
||||
private async uninstallCentralElastic(
|
||||
clusterId: string,
|
||||
): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
private async uninstallCentralElastic(clusterId: string): Promise<{ status: ClusterToolStatus; message: string }> {
|
||||
await this.elasticsearchService.undeploy(clusterId);
|
||||
return {
|
||||
status: 'not_installed',
|
||||
@@ -305,14 +257,14 @@ export class ClusterToolsService {
|
||||
toolId: ClusterToolId,
|
||||
clusterId: string,
|
||||
kubeconfig: string,
|
||||
): Promise<{ status: ClusterToolStatus; message?: string; details?: Record<string, unknown> }> {
|
||||
): Promise<{
|
||||
status: ClusterToolStatus;
|
||||
message?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}> {
|
||||
switch (toolId) {
|
||||
case 'cert-manager': {
|
||||
const helm = await this.helmService.status(
|
||||
CERT_MANAGER_RELEASE,
|
||||
CERT_MANAGER_NAMESPACE,
|
||||
kubeconfig,
|
||||
);
|
||||
const helm = await this.helmService.status(CERT_MANAGER_RELEASE, CERT_MANAGER_NAMESPACE, kubeconfig);
|
||||
if (!helm) return { status: 'not_installed' };
|
||||
return {
|
||||
status: this.mapHelmStatus(helm.status),
|
||||
@@ -323,17 +275,21 @@ export class ClusterToolsService {
|
||||
case 'cluster-issuer': {
|
||||
const api = this.customObjectsApi(kubeconfig);
|
||||
try {
|
||||
const res: any = await api.getClusterCustomObject(
|
||||
ISSUER_GROUP,
|
||||
ISSUER_VERSION,
|
||||
ISSUER_PLURAL,
|
||||
CLUSTER_ISSUER_NAME,
|
||||
);
|
||||
const conditions: any[] = res.body?.status?.conditions || [];
|
||||
const res: any = await api.getClusterCustomObject({
|
||||
group: ISSUER_GROUP,
|
||||
version: ISSUER_VERSION,
|
||||
plural: ISSUER_PLURAL,
|
||||
name: CLUSTER_ISSUER_NAME,
|
||||
});
|
||||
const conditions: any[] = res?.status?.conditions || [];
|
||||
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') {
|
||||
return { status: 'installed', message: 'Ready', details: { email } };
|
||||
return {
|
||||
status: 'installed',
|
||||
message: 'Ready',
|
||||
details: { email },
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'installing',
|
||||
@@ -357,9 +313,7 @@ export class ClusterToolsService {
|
||||
};
|
||||
return {
|
||||
status: map[state.status] || 'unknown',
|
||||
message: state.helmReleaseStatus
|
||||
? `helm: ${state.helmReleaseStatus}`
|
||||
: undefined,
|
||||
message: state.helmReleaseStatus ? `helm: ${state.helmReleaseStatus}` : undefined,
|
||||
details: state.health ? { health: state.health.status } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -394,14 +348,14 @@ export class ClusterToolsService {
|
||||
}
|
||||
|
||||
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. */
|
||||
private isMissingCrd(err: any): boolean {
|
||||
const msg = (err?.body?.message || err?.message || '').toString();
|
||||
return /could not find the requested resource|the server could not find|no matches for kind|NotFound/i.test(
|
||||
msg,
|
||||
);
|
||||
const body = err?.body;
|
||||
const bodyMsg = typeof body === 'string' ? body : body?.message;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Logger, BadRequestException, Inject, forwardRef, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Repository, DataSource, In } from 'typeorm';
|
||||
@@ -32,14 +23,10 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
private weightedRoundRobinState = new Map<string, number>();
|
||||
private clusterHealthCache = new Map<string, { cluster: Cluster; cachedAt: number }>();
|
||||
private healthCheckTimer?: NodeJS.Timeout;
|
||||
private readonly cacheTtlMs =
|
||||
Number(process.env.CLUSTER_HEALTH_CACHE_TTL_MS || 60_000);
|
||||
private readonly overloadedCpuThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_CPU_THRESHOLD || 0.85);
|
||||
private readonly overloadedMemoryThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_MEMORY_THRESHOLD || 0.85);
|
||||
private readonly overloadedPodThreshold =
|
||||
Number(process.env.CLUSTER_OVERLOADED_POD_THRESHOLD || 0.85);
|
||||
private readonly cacheTtlMs = Number(process.env.CLUSTER_HEALTH_CACHE_TTL_MS || 60_000);
|
||||
private readonly overloadedCpuThreshold = Number(process.env.CLUSTER_OVERLOADED_CPU_THRESHOLD || 0.85);
|
||||
private readonly overloadedMemoryThreshold = Number(process.env.CLUSTER_OVERLOADED_MEMORY_THRESHOLD || 0.85);
|
||||
private readonly overloadedPodThreshold = Number(process.env.CLUSTER_OVERLOADED_POD_THRESHOLD || 0.85);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Cluster)
|
||||
@@ -86,7 +73,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
const versionApi = kc.makeApiClient(k8s.VersionApi);
|
||||
const result = await versionApi.getCode();
|
||||
const info = result.body;
|
||||
const info = result;
|
||||
|
||||
this.logger.log(`Cluster connection OK: Kubernetes ${info.gitVersion}`);
|
||||
return {
|
||||
@@ -107,13 +94,13 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
// Validate kubeconfig by testing actual connection
|
||||
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||
if (!connectionTest.connected) {
|
||||
throw new BadRequestException(
|
||||
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||
);
|
||||
throw new BadRequestException(`Cannot connect to Kubernetes cluster: ${connectionTest.error}`);
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||
const existingDefaults = await this.clustersRepository.find({
|
||||
where: { isDefault: true },
|
||||
});
|
||||
for (const c of existingDefaults) {
|
||||
c.isDefault = false;
|
||||
await this.clustersRepository.save(c);
|
||||
@@ -150,23 +137,23 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async findAll(): Promise<Cluster[]> {
|
||||
return this.clustersRepository.find({
|
||||
select: [
|
||||
'id',
|
||||
'name',
|
||||
'description',
|
||||
'status',
|
||||
'apiServer',
|
||||
'region',
|
||||
'provider',
|
||||
'isDefault',
|
||||
'weight',
|
||||
'tags',
|
||||
'healthStatus',
|
||||
'lastHealthCheckedAt',
|
||||
'healthMessage',
|
||||
'availableResources',
|
||||
'createdAt',
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
status: true,
|
||||
apiServer: true,
|
||||
region: true,
|
||||
provider: true,
|
||||
isDefault: true,
|
||||
weight: true,
|
||||
tags: true,
|
||||
healthStatus: true,
|
||||
lastHealthCheckedAt: true,
|
||||
healthMessage: true,
|
||||
availableResources: true,
|
||||
createdAt: true,
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@@ -208,9 +195,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
if (dto.kubeconfig) {
|
||||
const connectionTest = await this.testConnection(dto.kubeconfig);
|
||||
if (!connectionTest.connected) {
|
||||
throw new BadRequestException(
|
||||
`Cannot connect to Kubernetes cluster: ${connectionTest.error}`,
|
||||
);
|
||||
throw new BadRequestException(`Cannot connect to Kubernetes cluster: ${connectionTest.error}`);
|
||||
}
|
||||
dto.status = ClusterStatus.ACTIVE;
|
||||
dto.kubeconfig = this.encryptKubeconfig(dto.kubeconfig);
|
||||
@@ -223,7 +208,9 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
if (dto.isDefault === true) {
|
||||
const existingDefaults = await this.clustersRepository.find({ where: { isDefault: true } });
|
||||
const existingDefaults = await this.clustersRepository.find({
|
||||
where: { isDefault: true },
|
||||
});
|
||||
for (const c of existingDefaults) {
|
||||
if (c.id !== id) {
|
||||
c.isDefault = false;
|
||||
@@ -248,9 +235,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
cluster.status = result.connected ? ClusterStatus.ACTIVE : ClusterStatus.INACTIVE;
|
||||
cluster.healthStatus = result.connected ? 'healthy' : 'unhealthy';
|
||||
cluster.lastHealthCheckedAt = new Date();
|
||||
cluster.healthMessage = result.connected
|
||||
? (result.version ? `Kubernetes ${result.version}` : 'Connection verified')
|
||||
: result.error || 'Connection failed';
|
||||
cluster.healthMessage = result.connected ? (result.version ? `Kubernetes ${result.version}` : 'Connection verified') : result.error || 'Connection failed';
|
||||
cluster.kubeconfig = this.encryptKubeconfig(cluster.kubeconfig);
|
||||
await this.clustersRepository.save(cluster);
|
||||
await this.recordHealthSnapshot(cluster, {
|
||||
@@ -267,7 +252,14 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
async findAllPublic(): Promise<Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'isDefault' | 'status'>[]> {
|
||||
return this.clustersRepository.find({
|
||||
select: ['id', 'name', 'region', 'provider', 'isDefault', 'status'],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
region: true,
|
||||
provider: true,
|
||||
isDefault: true,
|
||||
status: true,
|
||||
},
|
||||
where: { status: ClusterStatus.ACTIVE },
|
||||
order: { isDefault: 'DESC', name: 'ASC' },
|
||||
});
|
||||
@@ -281,22 +273,30 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
applicationId?: string;
|
||||
reason?: string;
|
||||
} = {},
|
||||
): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string }> {
|
||||
): Promise<{
|
||||
cluster: Cluster;
|
||||
pool?: ClusterPool;
|
||||
allocationLogId: string;
|
||||
}> {
|
||||
const estimatedRequest = this.estimateApplicationRequest(dto);
|
||||
let pool = dto.poolId
|
||||
? await this.poolsRepository.findOne({ where: { id: dto.poolId, isActive: true } })
|
||||
? await this.poolsRepository.findOne({
|
||||
where: { id: dto.poolId, isActive: true },
|
||||
})
|
||||
: null;
|
||||
if (dto.poolId && !pool) {
|
||||
throw new BadRequestException('Selected cluster pool is not active or does not exist');
|
||||
}
|
||||
if (!pool) {
|
||||
pool = await this.poolsRepository.findOne({
|
||||
pool =
|
||||
(await this.poolsRepository.findOne({
|
||||
where: { isActive: true, isDefault: true },
|
||||
order: { priority: 'ASC', createdAt: 'ASC' },
|
||||
}) || await this.poolsRepository.findOne({
|
||||
})) ||
|
||||
(await this.poolsRepository.findOne({
|
||||
where: { isActive: true },
|
||||
order: { priority: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
const candidates = await this.getCachedHealthyClusters(pool || undefined, options.excludeClusterIds || []);
|
||||
@@ -307,10 +307,18 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
const rejectionReasons: Record<string, any>[] = [];
|
||||
|
||||
for (const cluster of candidates) {
|
||||
const reserved = reservations.get(cluster.id) || { cpuMillicores: 0, memoryMi: 0, pods: 0 };
|
||||
const reserved = reservations.get(cluster.id) || {
|
||||
cpuMillicores: 0,
|
||||
memoryMi: 0,
|
||||
pods: 0,
|
||||
};
|
||||
const rejection = this.getClusterRejectionReason(cluster, estimatedRequest, reserved);
|
||||
if (rejection) {
|
||||
rejectionReasons.push({ clusterId: cluster.id, clusterName: cluster.name, reason: rejection });
|
||||
rejectionReasons.push({
|
||||
clusterId: cluster.id,
|
||||
clusterName: cluster.name,
|
||||
reason: rejection,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -331,7 +339,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
if (candidateScores.length === 0) {
|
||||
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
|
||||
const log = await this.allocationLogsRepository.save(
|
||||
this.allocationLogsRepository.create({
|
||||
userId,
|
||||
poolId: pool?.id,
|
||||
applicationId: options.applicationId,
|
||||
@@ -342,15 +351,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
rejectionReasons,
|
||||
status: 'failed',
|
||||
message: options.reason || 'No active healthy cluster has enough estimated capacity',
|
||||
}));
|
||||
throw new BadRequestException(
|
||||
`No active healthy cluster has enough capacity for this application (allocation log: ${log.id})`,
|
||||
}),
|
||||
);
|
||||
throw new BadRequestException(`No active healthy cluster has enough capacity for this application (allocation log: ${log.id})`);
|
||||
}
|
||||
|
||||
candidateScores.sort((a, b) => b.score - a.score);
|
||||
const selected = await this.findOne(candidateScores[0].clusterId);
|
||||
const log = await this.allocationLogsRepository.save(this.allocationLogsRepository.create({
|
||||
const log = await this.allocationLogsRepository.save(
|
||||
this.allocationLogsRepository.create({
|
||||
userId,
|
||||
poolId: pool?.id,
|
||||
applicationId: options.applicationId,
|
||||
@@ -361,10 +370,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
rejectionReasons,
|
||||
status: 'success',
|
||||
message: options.reason ? `${options.reason}: selected ${selected.name}` : `Selected ${selected.name}`,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Allocator selected cluster "${selected.name}" for user ${userId} (score ${candidateScores[0].score.toFixed(2)})`);
|
||||
return { cluster: selected, pool: pool || undefined, allocationLogId: log.id };
|
||||
return {
|
||||
cluster: selected,
|
||||
pool: pool || undefined,
|
||||
allocationLogId: log.id,
|
||||
};
|
||||
}
|
||||
|
||||
async attachAllocationToApplication(allocationLogId: string, applicationId: string): Promise<void> {
|
||||
@@ -373,17 +387,13 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async listAllocationLogs(limit = 100): Promise<ClusterAllocationLog[]> {
|
||||
return this.allocationLogsRepository.find({
|
||||
relations: ['selectedCluster', 'pool'],
|
||||
relations: { selectedCluster: true, pool: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
async markAllocationFailure(
|
||||
applicationId: string,
|
||||
clusterId: string | undefined,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
async markAllocationFailure(applicationId: string, clusterId: string | undefined, message: string): Promise<void> {
|
||||
if (clusterId) {
|
||||
// A single deployment failure (quota, image pull, app bug, transient
|
||||
// scheduling pressure) does NOT mean the cluster is unhealthy — flipping
|
||||
@@ -412,10 +422,19 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async chooseFallbackClusterForApplication(
|
||||
app: CreateApplicationDto & { id: string; userId: string; clusterId?: string; poolId?: string },
|
||||
app: CreateApplicationDto & {
|
||||
id: string;
|
||||
userId: string;
|
||||
clusterId?: string;
|
||||
poolId?: string;
|
||||
},
|
||||
failedClusterIds: string[],
|
||||
reason: string,
|
||||
): Promise<{ cluster: Cluster; pool?: ClusterPool; allocationLogId: string }> {
|
||||
): Promise<{
|
||||
cluster: Cluster;
|
||||
pool?: ClusterPool;
|
||||
allocationLogId: string;
|
||||
}> {
|
||||
return this.selectClusterForApplication(app, app.userId, {
|
||||
excludeClusterIds: failedClusterIds,
|
||||
applicationId: app.id,
|
||||
@@ -484,27 +503,17 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
where: { status: ClusterStatus.ACTIVE, id: undefined as any },
|
||||
});
|
||||
// Use raw query to exclude the deleted cluster
|
||||
const activeReplacement = await this.clustersRepository
|
||||
.createQueryBuilder('c')
|
||||
.where('c.id != :id', { id })
|
||||
.andWhere('c.status = :status', { status: ClusterStatus.ACTIVE })
|
||||
.getOne();
|
||||
const activeReplacement = await this.clustersRepository.createQueryBuilder('c').where('c.id != :id', { id }).andWhere('c.status = :status', { status: ClusterStatus.ACTIVE }).getOne();
|
||||
|
||||
if (activeReplacement) {
|
||||
const result = await this.dataSource.query(
|
||||
`UPDATE applications SET "clusterId" = $1 WHERE "clusterId" = $2`,
|
||||
[activeReplacement.id, id],
|
||||
);
|
||||
const result = await this.dataSource.query(`UPDATE applications SET "clusterId" = $1 WHERE "clusterId" = $2`, [activeReplacement.id, id]);
|
||||
const count = result?.[1] || 0;
|
||||
if (count > 0) {
|
||||
this.logger.log(`Reassigned ${count} application(s) from cluster "${cluster.name}" to "${activeReplacement.name}"`);
|
||||
}
|
||||
} else {
|
||||
// No replacement — nullify clusterId so apps aren't orphaned with a dangling FK
|
||||
await this.dataSource.query(
|
||||
`UPDATE applications SET "clusterId" = NULL WHERE "clusterId" = $1`,
|
||||
[id],
|
||||
);
|
||||
await this.dataSource.query(`UPDATE applications SET "clusterId" = NULL WHERE "clusterId" = $1`, [id]);
|
||||
this.logger.warn(`No active replacement cluster — cleared clusterId for apps on "${cluster.name}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
@@ -557,23 +566,36 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async findAllPools(): Promise<ClusterPool[]> {
|
||||
return this.poolsRepository.find({ order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' } });
|
||||
return this.poolsRepository.find({
|
||||
order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public pool list — returns pools with resolved cluster names for UI.
|
||||
*/
|
||||
async findAllPoolsPublic(): Promise<(ClusterPool & { clusters: Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[] })[]> {
|
||||
async findAllPoolsPublic(): Promise<
|
||||
(ClusterPool & {
|
||||
clusters: Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[];
|
||||
})[]
|
||||
> {
|
||||
const pools = await this.poolsRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { isDefault: 'DESC', priority: 'ASC', createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
const allClusterIds = [...new Set(pools.flatMap((p) => p.clusterIds))];
|
||||
const clusters = allClusterIds.length > 0
|
||||
const clusters =
|
||||
allClusterIds.length > 0
|
||||
? await this.clustersRepository.find({
|
||||
where: { id: In(allClusterIds) },
|
||||
select: ['id', 'name', 'region', 'provider', 'status'],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
region: true,
|
||||
provider: true,
|
||||
status: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -581,9 +603,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return pools.map((pool) => ({
|
||||
...pool,
|
||||
clusters: pool.clusterIds
|
||||
.map((id) => clusterMap.get(id))
|
||||
.filter(Boolean) as Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[],
|
||||
clusters: pool.clusterIds.map((id) => clusterMap.get(id)).filter(Boolean) as Pick<Cluster, 'id' | 'name' | 'region' | 'provider' | 'status'>[],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -657,12 +677,15 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
// least-apps strategy
|
||||
const appCounts: { clusterId: string; count: string }[] = await this.dataSource.query(`
|
||||
const appCounts: { clusterId: string; count: string }[] = await this.dataSource.query(
|
||||
`
|
||||
SELECT "clusterId", COUNT(*) as count
|
||||
FROM applications
|
||||
WHERE "clusterId" = ANY($1)
|
||||
GROUP BY "clusterId"
|
||||
`, [pool.clusterIds]);
|
||||
`,
|
||||
[pool.clusterIds],
|
||||
);
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
for (const row of appCounts) {
|
||||
@@ -681,8 +704,19 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Get resource usage for a specific cluster — nodes, total CPU/memory, pod counts.
|
||||
*/
|
||||
async getClusterResources(id: string, options: { allowCached?: boolean } = {}): Promise<{
|
||||
nodes: { name: string; status: string; roles: string; cpuCapacity: string; memoryCapacity: string; cpuAllocatable: string; memoryAllocatable: string; }[];
|
||||
async getClusterResources(
|
||||
id: string,
|
||||
options: { allowCached?: boolean } = {},
|
||||
): Promise<{
|
||||
nodes: {
|
||||
name: string;
|
||||
status: string;
|
||||
roles: string;
|
||||
cpuCapacity: string;
|
||||
memoryCapacity: string;
|
||||
cpuAllocatable: string;
|
||||
memoryAllocatable: string;
|
||||
}[];
|
||||
totalCpuCapacity: string;
|
||||
totalMemoryCapacity: string;
|
||||
totalCpuAllocatable: string;
|
||||
@@ -699,12 +733,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
appCount: number;
|
||||
}> {
|
||||
const cluster = await this.findOne(id);
|
||||
if (
|
||||
options.allowCached !== false &&
|
||||
cluster.availableResources &&
|
||||
cluster.lastHealthCheckedAt &&
|
||||
Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs
|
||||
) {
|
||||
if (options.allowCached !== false && cluster.availableResources && cluster.lastHealthCheckedAt && Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs) {
|
||||
return cluster.availableResources as any;
|
||||
}
|
||||
|
||||
@@ -716,10 +745,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
try {
|
||||
// Get nodes
|
||||
const nodesRes = await coreApi.listNode();
|
||||
const nodes = nodesRes.body.items.map((node) => {
|
||||
const nodes = nodesRes.items.map((node) => {
|
||||
const conditions = node.status?.conditions || [];
|
||||
const readyCondition = conditions.find((c) => c.type === 'Ready');
|
||||
const roles = Object.keys(node.metadata?.labels || {})
|
||||
const roles =
|
||||
Object.keys(node.metadata?.labels || {})
|
||||
.filter((l) => l.startsWith('node-role.kubernetes.io/'))
|
||||
.map((l) => l.replace('node-role.kubernetes.io/', ''))
|
||||
.join(', ') || 'worker';
|
||||
@@ -749,10 +779,10 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// Get all pods count
|
||||
const podsRes = await coreApi.listPodForAllNamespaces();
|
||||
const podCount = podsRes.body.items.length;
|
||||
const podCount = podsRes.items.length;
|
||||
let totalCpuRequested = 0;
|
||||
let totalMemoryRequested = 0;
|
||||
for (const pod of podsRes.body.items) {
|
||||
for (const pod of podsRes.items) {
|
||||
for (const container of pod.spec?.containers || []) {
|
||||
totalCpuRequested += this.parseCpuToMillicores(container.resources?.requests?.cpu || '0');
|
||||
totalMemoryRequested += this.parseMemoryToMi(container.resources?.requests?.memory || '0');
|
||||
@@ -760,10 +790,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Get app count for this cluster
|
||||
const appCountResult = await this.dataSource.query(
|
||||
`SELECT COUNT(*) as count FROM applications WHERE "clusterId" = $1`,
|
||||
[id],
|
||||
);
|
||||
const appCountResult = await this.dataSource.query(`SELECT COUNT(*) as count FROM applications WHERE "clusterId" = $1`, [id]);
|
||||
const appCount = parseInt(appCountResult[0]?.count || '0', 10);
|
||||
|
||||
const readyNodeCount = nodes.filter((node) => node.status === 'Ready').length;
|
||||
@@ -785,11 +812,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
readyNodeCount,
|
||||
appCount,
|
||||
};
|
||||
const healthStatus: ClusterHealthStatus = readyNodeCount === nodes.length && nodes.length > 0
|
||||
? 'healthy'
|
||||
: readyNodeCount > 0
|
||||
? 'degraded'
|
||||
: 'unhealthy';
|
||||
const healthStatus: ClusterHealthStatus = readyNodeCount === nodes.length && nodes.length > 0 ? 'healthy' : readyNodeCount > 0 ? 'degraded' : 'unhealthy';
|
||||
const healthMessage = `${readyNodeCount}/${nodes.length} nodes ready`;
|
||||
|
||||
await this.clustersRepository.update(id, {
|
||||
@@ -808,7 +831,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
await this.recordHealthSnapshot(cluster, { status: healthStatus, message: healthMessage, resources });
|
||||
await this.recordHealthSnapshot(cluster, {
|
||||
status: healthStatus,
|
||||
message: healthMessage,
|
||||
resources,
|
||||
});
|
||||
|
||||
return resources;
|
||||
} catch (err: any) {
|
||||
@@ -819,7 +846,10 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
lastHealthCheckedAt: new Date(),
|
||||
});
|
||||
this.clusterHealthCache.delete(id);
|
||||
await this.recordHealthSnapshot(cluster, { status: 'unhealthy', message: err.message });
|
||||
await this.recordHealthSnapshot(cluster, {
|
||||
status: 'unhealthy',
|
||||
message: err.message,
|
||||
});
|
||||
throw new BadRequestException(`Cannot fetch resources: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -846,11 +876,13 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ── 1. Namespace ──────────────────────────────────────────────
|
||||
try {
|
||||
await coreApi.readNamespace(buildNs);
|
||||
await coreApi.readNamespace({ name: buildNs });
|
||||
this.logger.log(`Namespace "${buildNs}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespace({ metadata: { name: buildNs } });
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespace({
|
||||
body: { metadata: { name: buildNs } },
|
||||
});
|
||||
this.logger.log(`Created namespace "${buildNs}"`);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -859,12 +891,16 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
// ── 2. ServiceAccount for Kaniko ──────────────────────────────
|
||||
try {
|
||||
await coreApi.readNamespacedServiceAccount(saName, buildNs);
|
||||
await coreApi.readNamespacedServiceAccount({
|
||||
name: saName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
this.logger.log(`ServiceAccount "${saName}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedServiceAccount(buildNs, {
|
||||
metadata: { name: saName, namespace: buildNs },
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedServiceAccount({
|
||||
namespace: buildNs,
|
||||
body: { metadata: { name: saName, namespace: buildNs } },
|
||||
});
|
||||
this.logger.log(`Created ServiceAccount "${saName}"`);
|
||||
} else {
|
||||
@@ -875,16 +911,22 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
// ── 3. Docker Registry PVC ────────────────────────────────────
|
||||
const registryPvcName = 'registry-data';
|
||||
try {
|
||||
await coreApi.readNamespacedPersistentVolumeClaim(registryPvcName, buildNs);
|
||||
await coreApi.readNamespacedPersistentVolumeClaim({
|
||||
name: registryPvcName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
this.logger.log(`PVC "${registryPvcName}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedPersistentVolumeClaim(buildNs, {
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedPersistentVolumeClaim({
|
||||
namespace: buildNs,
|
||||
body: {
|
||||
metadata: { name: registryPvcName, namespace: buildNs },
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
resources: { requests: { storage: '10Gi' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created PVC "${registryPvcName}" (10Gi)`);
|
||||
} else {
|
||||
@@ -895,36 +937,57 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
// ── 4. Docker Registry Deployment ─────────────────────────────
|
||||
const registryDeployName = 'registry';
|
||||
try {
|
||||
await appsApi.readNamespacedDeployment(registryDeployName, buildNs);
|
||||
await appsApi.readNamespacedDeployment({
|
||||
name: registryDeployName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
this.logger.log(`Deployment "${registryDeployName}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await appsApi.createNamespacedDeployment(buildNs, {
|
||||
metadata: { name: registryDeployName, namespace: buildNs, labels: { app: 'registry' } },
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await appsApi.createNamespacedDeployment({
|
||||
namespace: buildNs,
|
||||
body: {
|
||||
metadata: {
|
||||
name: registryDeployName,
|
||||
namespace: buildNs,
|
||||
labels: { app: 'registry' },
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
selector: { matchLabels: { app: 'registry' } },
|
||||
template: {
|
||||
metadata: { labels: { app: 'registry' } },
|
||||
spec: {
|
||||
containers: [{
|
||||
containers: [
|
||||
{
|
||||
name: 'registry',
|
||||
image: 'registry:2',
|
||||
ports: [{ containerPort: 5000 }],
|
||||
env: [{ name: 'REGISTRY_STORAGE_DELETE_ENABLED', value: 'true' }],
|
||||
volumeMounts: [{
|
||||
env: [
|
||||
{
|
||||
name: 'REGISTRY_STORAGE_DELETE_ENABLED',
|
||||
value: 'true',
|
||||
},
|
||||
],
|
||||
volumeMounts: [
|
||||
{
|
||||
name: 'registry-data',
|
||||
mountPath: '/var/lib/registry',
|
||||
}],
|
||||
},
|
||||
],
|
||||
resources: {
|
||||
requests: { cpu: '100m', memory: '128Mi' },
|
||||
limits: { cpu: '500m', memory: '512Mi' },
|
||||
},
|
||||
}],
|
||||
volumes: [{
|
||||
},
|
||||
],
|
||||
volumes: [
|
||||
{
|
||||
name: 'registry-data',
|
||||
persistentVolumeClaim: { claimName: registryPvcName },
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -938,17 +1001,27 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
// ── 5. Registry ClusterIP Service (Kaniko push + app pull) ───
|
||||
const registrySvcName = 'registry';
|
||||
try {
|
||||
await coreApi.readNamespacedService(registrySvcName, buildNs);
|
||||
await coreApi.readNamespacedService({
|
||||
name: registrySvcName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
this.logger.log(`Service "${registrySvcName}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedService(buildNs, {
|
||||
metadata: { name: registrySvcName, namespace: buildNs, labels: { app: 'registry' } },
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedService({
|
||||
namespace: buildNs,
|
||||
body: {
|
||||
metadata: {
|
||||
name: registrySvcName,
|
||||
namespace: buildNs,
|
||||
labels: { app: 'registry' },
|
||||
},
|
||||
spec: {
|
||||
type: 'ClusterIP',
|
||||
selector: { app: 'registry' },
|
||||
ports: [{ port: 5000, targetPort: 5000 as any, protocol: 'TCP' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created Registry ClusterIP Service (port 5000)`);
|
||||
} else {
|
||||
@@ -960,21 +1033,33 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
const registryNodePort = 30500;
|
||||
const registryNodePortName = 'registry-nodeport';
|
||||
try {
|
||||
await coreApi.readNamespacedService(registryNodePortName, buildNs);
|
||||
await coreApi.readNamespacedService({
|
||||
name: registryNodePortName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
this.logger.log(`Service "${registryNodePortName}" already exists`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedService(buildNs, {
|
||||
metadata: { name: registryNodePortName, namespace: buildNs, labels: { app: 'registry' } },
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedService({
|
||||
namespace: buildNs,
|
||||
body: {
|
||||
metadata: {
|
||||
name: registryNodePortName,
|
||||
namespace: buildNs,
|
||||
labels: { app: 'registry' },
|
||||
},
|
||||
spec: {
|
||||
type: 'NodePort',
|
||||
selector: { app: 'registry' },
|
||||
ports: [{
|
||||
ports: [
|
||||
{
|
||||
port: 5000,
|
||||
targetPort: 5000 as any,
|
||||
nodePort: registryNodePort,
|
||||
protocol: 'TCP',
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created Registry NodePort Service (${registryNodePort} → 5000)`);
|
||||
@@ -994,11 +1079,21 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
};
|
||||
try {
|
||||
await coreApi.readNamespacedSecret(registrySecretName, buildNs);
|
||||
await coreApi.replaceNamespacedSecret(registrySecretName, buildNs, kanikoRegistrySecret);
|
||||
await coreApi.readNamespacedSecret({
|
||||
name: registrySecretName,
|
||||
namespace: buildNs,
|
||||
});
|
||||
await coreApi.replaceNamespacedSecret({
|
||||
name: registrySecretName,
|
||||
namespace: buildNs,
|
||||
body: kanikoRegistrySecret,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret(buildNs, kanikoRegistrySecret);
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: buildNs,
|
||||
body: kanikoRegistrySecret,
|
||||
});
|
||||
this.logger.log(`Created registry-credentials Secret`);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -1011,17 +1106,14 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
|
||||
private async ensureK3sRegistryMirrors(
|
||||
appsApi: k8s.AppsV1Api,
|
||||
registryUrl: string,
|
||||
): Promise<void> {
|
||||
private async ensureK3sRegistryMirrors(appsApi: k8s.AppsV1Api, registryUrl: string): Promise<void> {
|
||||
const namespace = 'kube-system';
|
||||
const legacyDs = 'cloudhost-k3s-registry-config';
|
||||
try {
|
||||
await appsApi.deleteNamespacedDaemonSet(legacyDs, namespace);
|
||||
await appsApi.deleteNamespacedDaemonSet({ name: legacyDs, namespace });
|
||||
this.logger.log(`Removed legacy DaemonSet "${legacyDs}"`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode !== 404 && err.body?.code !== 404) {
|
||||
if (err.code !== 404 && err.body?.code !== 404) {
|
||||
this.logger.warn(`Could not delete legacy DaemonSet "${legacyDs}": ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -1097,12 +1189,16 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
|
||||
try {
|
||||
await appsApi.readNamespacedDaemonSet(dsName, namespace);
|
||||
await appsApi.replaceNamespacedDaemonSet(dsName, namespace, daemonSet);
|
||||
await appsApi.readNamespacedDaemonSet({ name: dsName, namespace });
|
||||
await appsApi.replaceNamespacedDaemonSet({
|
||||
name: dsName,
|
||||
namespace,
|
||||
body: daemonSet,
|
||||
});
|
||||
this.logger.log(`Updated DaemonSet "${dsName}"`);
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await appsApi.createNamespacedDaemonSet(namespace, daemonSet);
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await appsApi.createNamespacedDaemonSet({ namespace, body: daemonSet });
|
||||
this.logger.log(`Created DaemonSet "${dsName}"`);
|
||||
} else {
|
||||
throw err;
|
||||
@@ -1157,20 +1253,19 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
private async getCachedHealthyClusters(
|
||||
pool?: ClusterPool,
|
||||
excludeClusterIds: string[] = [],
|
||||
): Promise<Cluster[]> {
|
||||
const query = this.clustersRepository
|
||||
.createQueryBuilder('cluster')
|
||||
.where('cluster.status = :status', { status: ClusterStatus.ACTIVE });
|
||||
private async getCachedHealthyClusters(pool?: ClusterPool, excludeClusterIds: string[] = []): Promise<Cluster[]> {
|
||||
const query = this.clustersRepository.createQueryBuilder('cluster').where('cluster.status = :status', { status: ClusterStatus.ACTIVE });
|
||||
|
||||
if (pool?.clusterIds?.length) {
|
||||
query.andWhere('cluster.id IN (:...clusterIds)', { clusterIds: pool.clusterIds });
|
||||
query.andWhere('cluster.id IN (:...clusterIds)', {
|
||||
clusterIds: pool.clusterIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (excludeClusterIds.length > 0) {
|
||||
query.andWhere('cluster.id NOT IN (:...excludeClusterIds)', { excludeClusterIds });
|
||||
query.andWhere('cluster.id NOT IN (:...excludeClusterIds)', {
|
||||
excludeClusterIds,
|
||||
});
|
||||
}
|
||||
|
||||
const clusters = await query.getMany();
|
||||
@@ -1190,12 +1285,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
return cached.cluster;
|
||||
}
|
||||
|
||||
if (
|
||||
cluster.availableResources &&
|
||||
cluster.lastHealthCheckedAt &&
|
||||
Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs
|
||||
) {
|
||||
this.clusterHealthCache.set(cluster.id, { cluster, cachedAt: Date.now() });
|
||||
if (cluster.availableResources && cluster.lastHealthCheckedAt && Date.now() - new Date(cluster.lastHealthCheckedAt).getTime() < this.cacheTtlMs) {
|
||||
this.clusterHealthCache.set(cluster.id, {
|
||||
cluster,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
return cluster;
|
||||
}
|
||||
|
||||
@@ -1205,18 +1299,20 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.warn(`Health refresh failed for cluster "${cluster.name}": ${err.message}`);
|
||||
}
|
||||
|
||||
const fresh = await this.clustersRepository.findOne({ where: { id: cluster.id } });
|
||||
const fresh = await this.clustersRepository.findOne({
|
||||
where: { id: cluster.id },
|
||||
});
|
||||
const snapshot = fresh || cluster;
|
||||
this.clusterHealthCache.set(cluster.id, { cluster: snapshot, cachedAt: Date.now() });
|
||||
this.clusterHealthCache.set(cluster.id, {
|
||||
cluster: snapshot,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async refreshAllClusterHealth(): Promise<void> {
|
||||
const clusters = await this.clustersRepository.find({
|
||||
where: [
|
||||
{ status: ClusterStatus.ACTIVE },
|
||||
{ status: ClusterStatus.MAINTENANCE },
|
||||
],
|
||||
where: [{ status: ClusterStatus.ACTIVE }, { status: ClusterStatus.MAINTENANCE }],
|
||||
});
|
||||
|
||||
for (const cluster of clusters) {
|
||||
@@ -1231,7 +1327,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
private getClusterRejectionReason(
|
||||
cluster: Cluster,
|
||||
estimatedRequest: Record<string, any>,
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = {
|
||||
cpuMillicores: 0,
|
||||
memoryMi: 0,
|
||||
pods: 0,
|
||||
},
|
||||
): string | null {
|
||||
if (cluster.status !== ClusterStatus.ACTIVE) {
|
||||
return `status=${cluster.status}`;
|
||||
@@ -1272,7 +1372,11 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
appCount: number,
|
||||
strategy: PoolStrategy,
|
||||
desiredRegion?: string,
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = {
|
||||
cpuMillicores: 0,
|
||||
memoryMi: 0,
|
||||
pods: 0,
|
||||
},
|
||||
): number {
|
||||
const metrics = this.getResourceMetrics(cluster, estimatedRequest, appCount, reserved);
|
||||
const capacityScore = metrics.capacityScore;
|
||||
@@ -1302,10 +1406,26 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
cluster: Cluster,
|
||||
estimatedRequest: Record<string, any>,
|
||||
appCount: number,
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = { cpuMillicores: 0, memoryMi: 0, pods: 0 },
|
||||
reserved: { cpuMillicores: number; memoryMi: number; pods: number } = {
|
||||
cpuMillicores: 0,
|
||||
memoryMi: 0,
|
||||
pods: 0,
|
||||
},
|
||||
): {
|
||||
available: { cpuMillicores: number; memoryMi: number; storageMi: number; pods: number };
|
||||
utilization: { cpu: number; memory: number; storage: number; pods: number; appPressure: number; average: number };
|
||||
available: {
|
||||
cpuMillicores: number;
|
||||
memoryMi: number;
|
||||
storageMi: number;
|
||||
pods: number;
|
||||
};
|
||||
utilization: {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
storage: number;
|
||||
pods: number;
|
||||
appPressure: number;
|
||||
average: number;
|
||||
};
|
||||
capacityScore: number;
|
||||
} {
|
||||
const resources = cluster.availableResources || {};
|
||||
@@ -1339,12 +1459,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
appPressure: Math.min(appCount / 100, 1),
|
||||
average: 0,
|
||||
};
|
||||
utilization.average = (
|
||||
utilization.cpu * 0.35 +
|
||||
utilization.memory * 0.35 +
|
||||
utilization.pods * 0.2 +
|
||||
utilization.appPressure * 0.1
|
||||
);
|
||||
utilization.average = utilization.cpu * 0.35 + utilization.memory * 0.35 + utilization.pods * 0.2 + utilization.appPressure * 0.1;
|
||||
|
||||
const capacityScore =
|
||||
this.ratioScore(available.cpuMillicores, estimatedRequest.cpuMillicores) * 0.35 +
|
||||
@@ -1356,15 +1471,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
private resolveStrategy(strategy?: string): PoolStrategy {
|
||||
const strategies: PoolStrategy[] = [
|
||||
'round-robin',
|
||||
'weighted-round-robin',
|
||||
'least-apps',
|
||||
'least-loaded',
|
||||
'weighted-resource',
|
||||
'region-based',
|
||||
];
|
||||
return strategies.includes(strategy as PoolStrategy) ? strategy as PoolStrategy : 'weighted-resource';
|
||||
const strategies: PoolStrategy[] = ['round-robin', 'weighted-round-robin', 'least-apps', 'least-loaded', 'weighted-resource', 'region-based'];
|
||||
return strategies.includes(strategy as PoolStrategy) ? (strategy as PoolStrategy) : 'weighted-resource';
|
||||
}
|
||||
|
||||
private nextRoundRobinScore(scope: string): number {
|
||||
@@ -1395,13 +1503,16 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
if (clusterIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const rows: { clusterId: string; count: string }[] = await this.dataSource.query(`
|
||||
const rows: { clusterId: string; count: string }[] = await this.dataSource.query(
|
||||
`
|
||||
SELECT "clusterId", COUNT(*) as count
|
||||
FROM applications
|
||||
WHERE "clusterId" = ANY($1)
|
||||
AND "lifecycleStatus" = 'active'
|
||||
GROUP BY "clusterId"
|
||||
`, [clusterIds]);
|
||||
`,
|
||||
[clusterIds],
|
||||
);
|
||||
return new Map(rows.map((row) => [row.clusterId, parseInt(row.count, 10)]));
|
||||
}
|
||||
|
||||
@@ -1417,16 +1528,14 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
* @param excludeApplicationId skip an app's own in-flight deployment (used on
|
||||
* fallback re-allocation so the app doesn't reserve capacity against itself).
|
||||
*/
|
||||
private async getInFlightReservations(
|
||||
clusterIds: string[],
|
||||
excludeApplicationId?: string,
|
||||
): Promise<Map<string, { cpuMillicores: number; memoryMi: number; pods: number }>> {
|
||||
private async getInFlightReservations(clusterIds: string[], excludeApplicationId?: string): Promise<Map<string, { cpuMillicores: number; memoryMi: number; pods: number }>> {
|
||||
const result = new Map<string, { cpuMillicores: number; memoryMi: number; pods: number }>();
|
||||
if (clusterIds.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const rows: any[] = await this.dataSource.query(`
|
||||
const rows: any[] = await this.dataSource.query(
|
||||
`
|
||||
SELECT a.id as "applicationId",
|
||||
a."clusterId" as "clusterId",
|
||||
a."cpuRequest" as "cpuRequest",
|
||||
@@ -1442,14 +1551,20 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
JOIN applications a ON a.id = d."applicationId"
|
||||
WHERE a."clusterId" = ANY($1)
|
||||
AND d.status IN ('pending', 'building')
|
||||
`, [clusterIds]);
|
||||
`,
|
||||
[clusterIds],
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
if (excludeApplicationId && row.applicationId === excludeApplicationId) {
|
||||
continue;
|
||||
}
|
||||
const est = this.estimateApplicationRequest(row as CreateApplicationDto);
|
||||
const current = result.get(row.clusterId) || { cpuMillicores: 0, memoryMi: 0, pods: 0 };
|
||||
const current = result.get(row.clusterId) || {
|
||||
cpuMillicores: 0,
|
||||
memoryMi: 0,
|
||||
pods: 0,
|
||||
};
|
||||
current.cpuMillicores += est.cpuMillicores;
|
||||
current.memoryMi += est.memoryMi;
|
||||
current.pods += est.podEstimate;
|
||||
@@ -1477,7 +1592,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
): Promise<void> {
|
||||
const resources = snapshot.resources || cluster.availableResources || {};
|
||||
await this.healthRepository.save(this.healthRepository.create({
|
||||
await this.healthRepository.save(
|
||||
this.healthRepository.create({
|
||||
clusterId: cluster.id,
|
||||
status: snapshot.status,
|
||||
readyNodes: resources.readyNodeCount || resources.nodeCount || 0,
|
||||
@@ -1488,7 +1604,8 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
appCount: resources.appCount || 0,
|
||||
message: snapshot.message,
|
||||
resources,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private encryptKubeconfig(kubeconfig: string): string {
|
||||
@@ -1517,10 +1634,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
|
||||
const [, , ivRaw, tagRaw, encryptedRaw] = kubeconfig.split(':');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(ivRaw, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tagRaw, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedRaw, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
return Buffer.concat([decipher.update(Buffer.from(encryptedRaw, 'base64')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
private withDecryptedKubeconfig(cluster: Cluster): Cluster {
|
||||
|
||||
@@ -473,7 +473,7 @@ export class DeploymentsService {
|
||||
async findOne(id: string): Promise<Deployment> {
|
||||
const deployment = await this.deploymentsRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['application'],
|
||||
relations: { application: true },
|
||||
});
|
||||
if (!deployment) {
|
||||
throw new NotFoundException('Deployment not found');
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
OnModuleInit,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, Logger, ServiceUnavailableException, Inject, forwardRef, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as k8s from '@kubernetes/client-node';
|
||||
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). */
|
||||
private isClusterInternalHost(host: string): boolean {
|
||||
const h = host.toLowerCase();
|
||||
return (
|
||||
h.includes('svc.cluster.local') ||
|
||||
h.includes('.cluster.') ||
|
||||
h === 'elasticsearch' ||
|
||||
h === 'kibana'
|
||||
);
|
||||
return h.includes('svc.cluster.local') || h.includes('.cluster.') || h === 'elasticsearch' || h === 'kibana';
|
||||
}
|
||||
|
||||
private configuredElasticsearchHost(): string {
|
||||
return (
|
||||
this.configService.get<string>('elasticsearch.host') ||
|
||||
`${this.ES_NAME}.${this.ES_NAMESPACE}.svc.cluster.local`
|
||||
);
|
||||
return 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. */
|
||||
@@ -213,9 +197,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const delay = Math.min(60_000, 2_000 * Math.pow(2, this.reconnectAttempt));
|
||||
this.reconnectAttempt += 1;
|
||||
this.logger.warn(
|
||||
`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`,
|
||||
);
|
||||
this.logger.warn(`Elasticsearch port-forward lost (${reason}). Reconnecting in ${Math.round(delay / 1000)}s…`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
void this.ensureLocalElasticsearchAccess().then((ok) => {
|
||||
@@ -280,10 +262,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private async startDevPortForward(localPort: number, clusterId?: string): Promise<void> {
|
||||
const targetClusterId = clusterId || null;
|
||||
if (
|
||||
this.portForwardChild &&
|
||||
this.portForwardClusterId === targetClusterId
|
||||
) {
|
||||
if (this.portForwardChild && this.portForwardClusterId === targetClusterId) {
|
||||
return;
|
||||
}
|
||||
if (this.portForwardChild) {
|
||||
@@ -296,18 +275,8 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.portForwardKubeconfigFile = kubeconfigFile;
|
||||
this.portForwardClusterId = targetClusterId;
|
||||
|
||||
const args = [
|
||||
'--kubeconfig',
|
||||
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 args = ['--kubeconfig', 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'] });
|
||||
this.portForwardChild = child;
|
||||
this.portForwardStartedByUs = true;
|
||||
@@ -318,12 +287,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.portForwardStartedByUs = false;
|
||||
}
|
||||
if (wasOurs) {
|
||||
const reason =
|
||||
code !== 0 && code !== null
|
||||
? `exit code ${code}`
|
||||
: signal
|
||||
? `signal ${signal}`
|
||||
: 'connection closed';
|
||||
const reason = code !== 0 && code !== null ? `exit code ${code}` : signal ? `signal ${signal}` : 'connection closed';
|
||||
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.
|
||||
* Safe to call repeatedly (e.g. after cluster/API restart or port-forward drop).
|
||||
*/
|
||||
private async ensureLocalElasticsearchAccess(options?: {
|
||||
waitForCluster?: boolean;
|
||||
clusterId?: string;
|
||||
}): Promise<boolean> {
|
||||
private async ensureLocalElasticsearchAccess(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<boolean> {
|
||||
if (this.ensureInFlight) {
|
||||
await this.ensureInFlight;
|
||||
return this.probeElasticsearch();
|
||||
@@ -357,10 +318,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: {
|
||||
waitForCluster?: boolean;
|
||||
clusterId?: string;
|
||||
}): Promise<void> {
|
||||
private async ensureLocalElasticsearchAccessImpl(options?: { waitForCluster?: boolean; clusterId?: string }): Promise<void> {
|
||||
if (!this.shouldAutoPortForward()) {
|
||||
return;
|
||||
}
|
||||
@@ -395,9 +353,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
this.logger.log(`Elasticsearch reachable at 127.0.0.1:${port}`);
|
||||
} else {
|
||||
this.stopDevPortForward();
|
||||
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`,
|
||||
);
|
||||
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`);
|
||||
this.schedulePortForwardReconnect('probe timeout');
|
||||
}
|
||||
}
|
||||
@@ -413,10 +369,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
if (this.isClusterInternalHost(configured)) {
|
||||
if (this.isRunningInKubernetes()) {
|
||||
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}).`
|
||||
);
|
||||
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}).`;
|
||||
}
|
||||
return (
|
||||
'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) {
|
||||
const cluster = clusterId
|
||||
? await this.clustersService.findOne(clusterId)
|
||||
: await this.clustersService.getDefault();
|
||||
const cluster = clusterId ? await this.clustersService.findOne(clusterId) : await this.clustersService.getDefault();
|
||||
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
@@ -455,18 +406,17 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
*/
|
||||
async getDeployState(clusterId?: string): Promise<LoggingDeployState> {
|
||||
const { cluster } = await this.getK8sClients(clusterId);
|
||||
const helmStatus = await this.helmService.status(
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
cluster.kubeconfig,
|
||||
);
|
||||
const helmStatus = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
|
||||
|
||||
let hasEsWorkload = false;
|
||||
try {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(cluster.kubeconfig);
|
||||
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;
|
||||
} catch {
|
||||
hasEsWorkload = false;
|
||||
@@ -512,19 +462,19 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
const kc = new k8s.KubeConfig();
|
||||
kc.loadFromString(kubeconfig);
|
||||
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
|
||||
const provisioner =
|
||||
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
|
||||
const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
|
||||
|
||||
try {
|
||||
await storageApi.readStorageClass(storageClass);
|
||||
await storageApi.readStorageClass({ name: storageClass });
|
||||
return;
|
||||
} catch (err: any) {
|
||||
if (err.statusCode !== 404 && err.body?.code !== 404) {
|
||||
if (err.code !== 404 && err.body?.code !== 404) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await storageApi.createStorageClass({
|
||||
body: {
|
||||
apiVersion: 'storage.k8s.io/v1',
|
||||
kind: 'StorageClass',
|
||||
metadata: { name: storageClass },
|
||||
@@ -532,6 +482,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
allowVolumeExpansion: true,
|
||||
reclaimPolicy: 'Delete',
|
||||
volumeBindingMode: 'WaitForFirstConsumer',
|
||||
},
|
||||
});
|
||||
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
|
||||
}
|
||||
@@ -578,37 +529,25 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
try {
|
||||
// Check ES pods
|
||||
const esPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=elasticsearch',
|
||||
);
|
||||
const esPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=elasticsearch',
|
||||
});
|
||||
|
||||
const kibanaPods = await coreApi.listNamespacedPod(
|
||||
this.ES_NAMESPACE,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'app=kibana',
|
||||
);
|
||||
const kibanaPods = await coreApi.listNamespacedPod({
|
||||
namespace: this.ES_NAMESPACE,
|
||||
labelSelector: 'app=kibana',
|
||||
});
|
||||
|
||||
if (esPods.body.items.length === 0) {
|
||||
if (esPods.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const esPod = esPods.body.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
);
|
||||
const esPod = esPods.items[0];
|
||||
const isEsReady = esPod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True');
|
||||
|
||||
const kibanaPod = kibanaPods.body.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some(
|
||||
(c) => c.type === 'Ready' && c.status === 'True',
|
||||
) || false;
|
||||
const kibanaPod = kibanaPods.items[0];
|
||||
const isKibanaReady = kibanaPod?.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True') || false;
|
||||
|
||||
return {
|
||||
status: isEsReady ? 'green' : 'yellow',
|
||||
@@ -640,22 +579,14 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
timeout: '10m',
|
||||
});
|
||||
} catch (error: any) {
|
||||
const release = await this.helmService.status(
|
||||
LOGGING_HELM_RELEASE,
|
||||
LOGGING_HELM_NAMESPACE,
|
||||
cluster.kubeconfig,
|
||||
);
|
||||
const release = await this.helmService.status(LOGGING_HELM_RELEASE, LOGGING_HELM_NAMESPACE, cluster.kubeconfig);
|
||||
if (!release) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.warn(
|
||||
`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`,
|
||||
);
|
||||
this.logger.warn(`Helm logging install reported an error but release exists (${release.status}); continuing: ${error.message}`);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`,
|
||||
);
|
||||
this.logger.log(`Central logging stack applied via Helm (${LOGGING_HELM_RELEASE} in ${LOGGING_HELM_NAMESPACE})`);
|
||||
|
||||
const state = await this.getDeployState(clusterId);
|
||||
|
||||
@@ -688,7 +619,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* 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 {
|
||||
host: this.effectiveElasticsearchHost(),
|
||||
port: this.configService.get<number>('elasticsearch.port') || 9200,
|
||||
@@ -744,12 +680,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
const must: any[] = [
|
||||
{
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { ownerId: userId } },
|
||||
{ term: { 'ownerId.keyword': userId } },
|
||||
{ term: { namespace } },
|
||||
{ term: { 'namespace.keyword': namespace } },
|
||||
],
|
||||
should: [{ term: { ownerId: userId } }, { term: { 'ownerId.keyword': userId } }, { term: { namespace } }, { term: { 'namespace.keyword': namespace } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
},
|
||||
@@ -758,10 +689,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.applicationId) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { applicationId: filters.applicationId } },
|
||||
{ term: { 'applicationId.keyword': filters.applicationId } },
|
||||
],
|
||||
should: [{ term: { applicationId: filters.applicationId } }, { term: { 'applicationId.keyword': filters.applicationId } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -784,10 +712,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.workload) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { workload: filters.workload } },
|
||||
{ term: { 'workload.keyword': filters.workload } },
|
||||
],
|
||||
should: [{ term: { workload: filters.workload } }, { term: { 'workload.keyword': filters.workload } }],
|
||||
minimum_should_match: 1,
|
||||
},
|
||||
});
|
||||
@@ -796,10 +721,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
if (filters.level) {
|
||||
must.push({
|
||||
bool: {
|
||||
should: [
|
||||
{ term: { level: filters.level.toLowerCase() } },
|
||||
{ term: { 'level.keyword': filters.level.toLowerCase() } },
|
||||
],
|
||||
should: [{ term: { level: filters.level.toLowerCase() } }, { term: { 'level.keyword': filters.level.toLowerCase() } }],
|
||||
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> {
|
||||
const deployed = await this.isDeployed(clusterId);
|
||||
if (!deployed) {
|
||||
throw new ServiceUnavailableException(
|
||||
'Central logging is not configured. Ask an administrator to deploy Elasticsearch.',
|
||||
);
|
||||
throw new ServiceUnavailableException('Central logging is not configured. Ask an administrator to deploy Elasticsearch.');
|
||||
}
|
||||
|
||||
if (this.shouldAutoPortForward()) {
|
||||
@@ -896,12 +816,7 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
private normalizeHit(hit: any): NormalizedLogEntry {
|
||||
const src = hit._source || {};
|
||||
const message =
|
||||
src.message ||
|
||||
src.log ||
|
||||
src.msg ||
|
||||
(typeof src.error === 'string' ? src.error : src.error?.message) ||
|
||||
'';
|
||||
const message = src.message || src.log || src.msg || (typeof src.error === 'string' ? src.error : src.error?.message) || '';
|
||||
|
||||
return {
|
||||
id: hit._id || '',
|
||||
@@ -942,7 +857,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchLogStats(
|
||||
userId: string,
|
||||
filters: { applicationId?: string; applicationName?: string; workload?: string; period?: string },
|
||||
filters: {
|
||||
applicationId?: string;
|
||||
applicationName?: string;
|
||||
workload?: string;
|
||||
period?: string;
|
||||
},
|
||||
clusterId?: string,
|
||||
): Promise<LogStatsResult> {
|
||||
const periodMap: Record<string, string> = {
|
||||
@@ -965,8 +885,12 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
query: { bool: { must } },
|
||||
size: 0,
|
||||
aggs: {
|
||||
by_level: { terms: { field: 'level.keyword', size: 10, missing: 'unknown' } },
|
||||
by_workload: { terms: { field: 'workload.keyword', size: 10, missing: 'app' } },
|
||||
by_level: {
|
||||
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' } } },
|
||||
warn_count: { filter: { term: { 'level.keyword': 'warn' } } },
|
||||
},
|
||||
@@ -994,7 +918,13 @@ export class ElasticsearchService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
async searchRecentErrors(
|
||||
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,
|
||||
): Promise<NormalizedLogEntry[]> {
|
||||
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));
|
||||
}
|
||||
|
||||
async getLoggingStatus(
|
||||
clusterId?: string,
|
||||
): Promise<{ available: boolean; deployed: boolean; recovering?: boolean; message?: string }> {
|
||||
async getLoggingStatus(clusterId?: string): Promise<{
|
||||
available: boolean;
|
||||
deployed: boolean;
|
||||
recovering?: boolean;
|
||||
message?: string;
|
||||
}> {
|
||||
let deployed = await this.isDeployed(clusterId);
|
||||
if (!deployed && this.shouldAutoPortForward()) {
|
||||
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
@@ -24,10 +24,7 @@ export class RegistryService {
|
||||
/** Registry host:port used for build push and app image pull. */
|
||||
getRegistryUrl(): string {
|
||||
const buildNs = this.getBuildNamespace();
|
||||
const url =
|
||||
this.configService.get<string>('registry.pullUrl') ||
|
||||
this.configService.get<string>('registry.url') ||
|
||||
`registry.${buildNs}.svc.cluster.local:5000`;
|
||||
const url = this.configService.get<string>('registry.pullUrl') || this.configService.get<string>('registry.url') || `registry.${buildNs}.svc.cluster.local:5000`;
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
@@ -67,15 +64,14 @@ export class RegistryService {
|
||||
|
||||
buildDockerConfigJson(): string {
|
||||
const { username, password } = this.getRegistryCredentials();
|
||||
const auth =
|
||||
username && password
|
||||
? Buffer.from(`${username}:${password}`).toString('base64')
|
||||
: '';
|
||||
const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : '';
|
||||
const host = this.getRegistryUrl();
|
||||
return JSON.stringify({
|
||||
auths: {
|
||||
[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 {
|
||||
await coreApi.replaceNamespacedSecret(secretName, namespace, secret);
|
||||
await coreApi.replaceNamespacedSecret({
|
||||
name: secretName,
|
||||
namespace,
|
||||
body: secret,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err.statusCode === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret(namespace, secret);
|
||||
if (err.code === 404 || err.body?.code === 404) {
|
||||
await coreApi.createNamespacedSecret({ namespace, body: secret });
|
||||
this.logger.log(`Created ${secretName} in ${namespace}`);
|
||||
} else {
|
||||
throw err;
|
||||
|
||||
@@ -264,7 +264,7 @@ export class SnapshotsService implements OnModuleInit {
|
||||
async findOne(snapshotId: string, userId: string): Promise<AppSnapshot> {
|
||||
const snapshot = await this.snapshotsRepo.findOne({
|
||||
where: { id: snapshotId },
|
||||
relations: ['application'],
|
||||
relations: { application: true },
|
||||
});
|
||||
if (!snapshot) throw new NotFoundException('Snapshot not found');
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export class TicketsService {
|
||||
async findMyTickets(userId: string): Promise<Ticket[]> {
|
||||
return this.ticketsRepo.find({
|
||||
where: { userId },
|
||||
relations: ['messages', 'messages.sender'],
|
||||
relations: { messages: { sender: true } },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@@ -54,7 +54,7 @@ export class TicketsService {
|
||||
async findOne(ticketId: string, userId: string, userRole: UserRole): Promise<Ticket> {
|
||||
const ticket = await this.ticketsRepo.findOne({
|
||||
where: { id: ticketId },
|
||||
relations: ['messages', 'messages.sender', 'user'],
|
||||
relations: { messages: { sender: true }, user: true },
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
@@ -140,7 +140,7 @@ export class TicketsService {
|
||||
}
|
||||
return this.ticketsRepo.find({
|
||||
where,
|
||||
relations: ['user', 'messages'],
|
||||
relations: { user: true, messages: true },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export class TicketsService {
|
||||
|
||||
return this.ticketsRepo.find({
|
||||
where,
|
||||
relations: ['user', 'messages'],
|
||||
relations: { user: true, messages: true },
|
||||
order: { updatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export class TicketsService {
|
||||
byDepartment: Record<string, { total: number; open: number; answered: number; closed: number }>;
|
||||
}> {
|
||||
const allTickets = await this.ticketsRepo.find({
|
||||
relations: ['messages', 'messages.sender'],
|
||||
relations: { messages: { sender: true } },
|
||||
});
|
||||
|
||||
const totalTickets = allTickets.length;
|
||||
|
||||
@@ -68,8 +68,17 @@ export class UsersService {
|
||||
|
||||
const users = await this.usersRepository.find({
|
||||
where,
|
||||
select: ['id', 'email', 'firstName', 'lastName', 'role', 'isActive', 'namespace', 'createdAt'],
|
||||
relations: ['applications'],
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
namespace: true,
|
||||
createdAt: true,
|
||||
},
|
||||
relations: { applications: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "jest", "multer"],
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
# ---- Stage 1: Dependencies ----
|
||||
FROM node:20-alpine AS deps
|
||||
FROM node:24-alpine AS deps
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# ---- Stage 2: Build ----
|
||||
FROM node:20-alpine AS builder
|
||||
FROM node:24-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
@@ -18,7 +18,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 3: Production ----
|
||||
FROM node:20-alpine AS production
|
||||
FROM node:24-alpine AS production
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
|
||||
Vendored
+2
-1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// 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.
|
||||
|
||||
Generated
+2557
-2098
File diff suppressed because it is too large
Load Diff
+24
-24
@@ -9,33 +9,33 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^9.122.0",
|
||||
"@react-three/fiber": "^8.18.0",
|
||||
"@react-three/postprocessing": "^2.19.1",
|
||||
"@tanstack/react-query": "^5.17.0",
|
||||
"axios": "^1.6.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"@react-three/postprocessing": "^3.0.4",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"axios": "^1.17.0",
|
||||
"clsx": "^2.1.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"framer-motion": "^12.40.0",
|
||||
"lenis": "^1.3.23",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next": "14.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.0",
|
||||
"react-toastify": "^11.0.5",
|
||||
"three": "^0.169.0",
|
||||
"zustand": "^4.5.0"
|
||||
"lucide-react": "^1.18.0",
|
||||
"next": "16.2.9",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-toastify": "^11.1.0",
|
||||
"three": "^0.184.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/three": "^0.169.0",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
"postcss": "^8.4.33",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3"
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.184.1",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "16.2.9",
|
||||
"postcss": "^8.5.15",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -14,23 +14,29 @@ export function generateStaticParams() {
|
||||
return locales.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { lang: string };
|
||||
}): Promise<Metadata> {
|
||||
export async function generateMetadata(
|
||||
props: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}
|
||||
): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
if (!isLocale(params.lang)) return {};
|
||||
const dict = await getDictionary(params.lang);
|
||||
return { title: dict.meta.title, description: dict.meta.description };
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
export default async function RootLayout(
|
||||
props: {
|
||||
children: React.ReactNode;
|
||||
params: { lang: string };
|
||||
}) {
|
||||
params: Promise<{ lang: string }>;
|
||||
}
|
||||
) {
|
||||
const params = await props.params;
|
||||
|
||||
const {
|
||||
children
|
||||
} = props;
|
||||
|
||||
if (!isLocale(params.lang)) notFound();
|
||||
const locale: Locale = params.lang;
|
||||
const dict = await getDictionary(locale);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@config '../../tailwind.config.ts';
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@@ -82,19 +81,20 @@
|
||||
transition-shadow duration-200;
|
||||
}
|
||||
.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 ──────────────────────────────────────────── */
|
||||
.badge {
|
||||
@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-red { @apply badge bg-red-100 text-red-700; }
|
||||
.badge-blue { @apply badge bg-blue-100 text-blue-700; }
|
||||
.badge-yellow { @apply badge bg-amber-100 text-amber-700; }
|
||||
.badge-gray { @apply badge bg-gray-100 text-gray-600; }
|
||||
.badge-purple { @apply badge bg-purple-100 text-purple-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 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 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 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 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 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 {
|
||||
|
||||
+25
-7
@@ -1,7 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -11,13 +15,27 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user