@nitra/cursor 1.8.114 → 1.8.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mdc/k8s.mdc +154 -4
- package/package.json +1 -1
- package/scripts/check-k8s.mjs +671 -14
package/mdc/k8s.mdc
CHANGED
|
@@ -96,15 +96,19 @@ jobs:
|
|
|
96
96
|
run: bun run lint-k8s
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
## Deployment: `resources`
|
|
99
|
+
## Deployment: `resources.requests.cpu`
|
|
100
100
|
|
|
101
|
-
У **`Deployment`** у кожному **`containers`**
|
|
101
|
+
У **`Deployment`** у кожному **`containers`** має бути **`resources.requests.cpu`** — непорожнє значення (наприклад **`"500m"`**, **`"100m"`** або число на кшталт **`0.5`**). Це гарантує, що Kubernetes планує pod з мінімальним бюджетом CPU і коректно працює з HPA (CPU target %).
|
|
102
|
+
|
|
103
|
+
Якщо для сервісу ще не обрано конкретне значення — став **`"0.5"`** як безпечний мінімум:
|
|
102
104
|
|
|
103
105
|
```yaml
|
|
104
|
-
resources:
|
|
106
|
+
resources:
|
|
107
|
+
requests:
|
|
108
|
+
cpu: '0.5' # довільне значення; 0.5 — дефолт, якщо нічого специфічного ще не обрано
|
|
105
109
|
```
|
|
106
110
|
|
|
107
|
-
**`check k8s`** перевіряє
|
|
111
|
+
**`check k8s`** перевіряє присутність і непорожність **`resources.requests.cpu`** у кожному документі **Deployment** під **`k8s`**. Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
|
|
108
112
|
|
|
109
113
|
Образ **`hasura/graphql-engine`**: дозволений лише канонічний тег із константи **`HASURA_GRAPHQL_ENGINE_IMAGE`** у **`check-k8s.mjs`** (допускається префікс **`docker.io/`**); решта — помилка **check k8s**.
|
|
110
114
|
|
|
@@ -282,6 +286,152 @@ data:
|
|
|
282
286
|
|
|
283
287
|
**`check k8s`:** заборонено **`kind: Ingress`**.
|
|
284
288
|
|
|
289
|
+
## Deployment: обов'язкові `hpa.yaml`, `pdb.yaml`, `topologySpreadConstraints`
|
|
290
|
+
|
|
291
|
+
Для **кожного** `kind: Deployment` під **`k8s/`** у тому ж каталозі мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — мати канонічні **`spec.template.spec.topologySpreadConstraints`**. Скрипт звіряє прив'язку за іменами:
|
|
292
|
+
|
|
293
|
+
- **`hpa.yaml`** — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
|
|
294
|
+
- **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
|
|
295
|
+
- **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
|
|
296
|
+
|
|
297
|
+
### Env-залежні межі (за сегментом після `/k8s/`)
|
|
298
|
+
|
|
299
|
+
**Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
|
|
300
|
+
|
|
301
|
+
- HPA: `minReplicas` — рівно **1**, `maxReplicas` — рівно **1** (у деві не масштабуємо).
|
|
302
|
+
- PDB: `minAvailable` — рівно **0**.
|
|
303
|
+
|
|
304
|
+
**Прод-середовища** — усе інше (будь-який overlay без суфікса `-qa`):
|
|
305
|
+
|
|
306
|
+
- HPA: `minReplicas` — мінімум **2**, `maxReplicas` — мінімум **2**.
|
|
307
|
+
- PDB: `minAvailable` — мінімум **1**.
|
|
308
|
+
|
|
309
|
+
### Прод-оверрайди у `kustomization.yaml`
|
|
310
|
+
|
|
311
|
+
Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-оверлеї `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення:
|
|
312
|
+
|
|
313
|
+
- для `HorizontalPodAutoscaler`: `spec.minReplicas` **і** `spec.maxReplicas` (щоб у проді вийшло ≥2).
|
|
314
|
+
- для `PodDisruptionBudget`: `spec.minAvailable` (щоб у проді вийшло ≥1).
|
|
315
|
+
|
|
316
|
+
Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** оверрайду відповідного поля. Конкретне значення має задовольняти прод-мінімуми — це видно у вмісті patch і остаточно матеріалізується під час збірки Kustomize.
|
|
317
|
+
|
|
318
|
+
```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
|
|
319
|
+
patches:
|
|
320
|
+
- target:
|
|
321
|
+
kind: HorizontalPodAutoscaler
|
|
322
|
+
name: backend-api
|
|
323
|
+
patch: |-
|
|
324
|
+
- op: replace
|
|
325
|
+
path: /spec/minReplicas
|
|
326
|
+
value: 3
|
|
327
|
+
- op: replace
|
|
328
|
+
path: /spec/maxReplicas
|
|
329
|
+
value: 10
|
|
330
|
+
- target:
|
|
331
|
+
kind: PodDisruptionBudget
|
|
332
|
+
name: backend-api
|
|
333
|
+
patch: |-
|
|
334
|
+
- op: replace
|
|
335
|
+
path: /spec/minAvailable
|
|
336
|
+
value: 1
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Приклади
|
|
340
|
+
|
|
341
|
+
```yaml title="k8s/base/hpa.yaml"
|
|
342
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
|
|
343
|
+
apiVersion: autoscaling/v2
|
|
344
|
+
kind: HorizontalPodAutoscaler
|
|
345
|
+
metadata:
|
|
346
|
+
name: backend-api
|
|
347
|
+
spec:
|
|
348
|
+
scaleTargetRef:
|
|
349
|
+
apiVersion: apps/v1
|
|
350
|
+
kind: Deployment
|
|
351
|
+
name: backend-api
|
|
352
|
+
minReplicas: 1 # dev-like; прод-оверлеї перевизначають на ≥ 2
|
|
353
|
+
maxReplicas: 1 # dev-like; прод-оверлеї перевизначають на ≥ 2
|
|
354
|
+
metrics:
|
|
355
|
+
- type: Resource
|
|
356
|
+
resource:
|
|
357
|
+
name: cpu
|
|
358
|
+
target:
|
|
359
|
+
type: Utilization
|
|
360
|
+
averageUtilization: 70
|
|
361
|
+
behavior:
|
|
362
|
+
scaleUp:
|
|
363
|
+
stabilizationWindowSeconds: 15
|
|
364
|
+
policies:
|
|
365
|
+
- type: Percent
|
|
366
|
+
value: 100
|
|
367
|
+
periodSeconds: 30
|
|
368
|
+
- type: Pods
|
|
369
|
+
value: 4
|
|
370
|
+
periodSeconds: 30
|
|
371
|
+
selectPolicy: Max
|
|
372
|
+
scaleDown:
|
|
373
|
+
stabilizationWindowSeconds: 300
|
|
374
|
+
policies:
|
|
375
|
+
- type: Percent
|
|
376
|
+
value: 25
|
|
377
|
+
periodSeconds: 120
|
|
378
|
+
selectPolicy: Min
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```yaml title="k8s/base/pdb.yaml"
|
|
382
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/poddisruptionbudget-policy-v1.json
|
|
383
|
+
apiVersion: policy/v1
|
|
384
|
+
kind: PodDisruptionBudget
|
|
385
|
+
metadata:
|
|
386
|
+
name: backend-api
|
|
387
|
+
spec:
|
|
388
|
+
minAvailable: 0 # прод: >= 1
|
|
389
|
+
selector:
|
|
390
|
+
matchLabels:
|
|
391
|
+
app: backend-api
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```yaml title="k8s/base/deploy.yaml (фрагмент)"
|
|
395
|
+
spec:
|
|
396
|
+
template:
|
|
397
|
+
spec:
|
|
398
|
+
topologySpreadConstraints:
|
|
399
|
+
- maxSkew: 1
|
|
400
|
+
topologyKey: kubernetes.io/hostname
|
|
401
|
+
whenUnsatisfiable: ScheduleAnyway
|
|
402
|
+
labelSelector:
|
|
403
|
+
matchLabels:
|
|
404
|
+
app: backend-api
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
В overlays для проду `minReplicas`, `maxReplicas`, `minAvailable` підіймаєш до прод-мінімумів через **Kustomize patches** або окремі `hpa.yaml` / `pdb.yaml` у каталозі overlay (тоді перевірка спрацьовує за їхнім середовищем).
|
|
408
|
+
|
|
409
|
+
## HorizontalPodAutoscaler: `autoscaling/v2`
|
|
410
|
+
|
|
411
|
+
У маніфестах під **`k8s`** заборонено **`apiVersion: autoscaling/v1`** (legacy HPA з єдиною метрикою CPU). Мігруй **HorizontalPodAutoscaler** на **`autoscaling/v2`**: поле **`spec.metrics`** (замість **`spec.targetCPUUtilizationPercentage`**) з **`type: Resource`** і **`target.type: Utilization`** / **`AverageUtilization`** — підтримує декілька метрик і зовнішні метрики. `check k8s` падає на будь-якому документі з **`apiVersion: autoscaling/v1`**.
|
|
412
|
+
|
|
413
|
+
```yaml
|
|
414
|
+
# yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
|
|
415
|
+
apiVersion: autoscaling/v2
|
|
416
|
+
kind: HorizontalPodAutoscaler
|
|
417
|
+
metadata:
|
|
418
|
+
name: app
|
|
419
|
+
spec:
|
|
420
|
+
scaleTargetRef:
|
|
421
|
+
apiVersion: apps/v1
|
|
422
|
+
kind: Deployment
|
|
423
|
+
name: app
|
|
424
|
+
minReplicas: 1
|
|
425
|
+
maxReplicas: 5
|
|
426
|
+
metrics:
|
|
427
|
+
- type: Resource
|
|
428
|
+
resource:
|
|
429
|
+
name: cpu
|
|
430
|
+
target:
|
|
431
|
+
type: Utilization
|
|
432
|
+
averageUtilization: 70
|
|
433
|
+
```
|
|
434
|
+
|
|
285
435
|
3. **JSON patch у kustomization:** де можливо, змінюй ресурс через **`op: replace`** (одна операція на `path`), а не пару **`remove` + `add`** на той самий шлях. **`add`** / **`remove`** лишай лише коли **`replace`** не підходить (наприклад додати новий ключ або прибрати поле без заміни).
|
|
286
436
|
|
|
287
437
|
```yaml title="overlay/kustomization.yaml (фрагмент)"
|
package/package.json
CHANGED
package/scripts/check-k8s.mjs
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
* (datree за замовчуванням: GitHub Pages `https://datreeio.github.io/CRDs-catalog/…`).
|
|
8
8
|
*
|
|
9
9
|
* Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
|
|
10
|
-
* **`spec.template.spec.containers[]`** має бути ключ **`resources`**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* **`spec.template.spec.containers[]`** має бути ключ **`resources`** з непорожнім
|
|
11
|
+
* **`resources.requests.cpu`** (рядок на кшталт **`"500m"`** або число; якщо значення ще не обрано —
|
|
12
|
+
* рекомендоване за замовчуванням — **`DEFAULT_CONTAINER_CPU_REQUEST`** = **`"0.5"`**). Поле **`imagePullPolicy`**
|
|
13
|
+
* не перевіряється — діють типові правила Kubernetes (`:latest` або коли тег не вказано → **Always**,
|
|
14
|
+
* інші теги → **IfNotPresent**). Якщо серед **`containers`** / **`initContainers`** є образ
|
|
15
|
+
* **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`** (див. k8s.mdc).
|
|
15
16
|
*
|
|
16
17
|
* **Namespace і Kustomize:** YAML у **`…/k8s/base/`** (окрім імені **`kustomization.yaml`**)
|
|
17
18
|
* завжди має **непорожній** **`metadata.namespace`** у відповідних документах (узгоджено з dev у репозиторії),
|
|
@@ -21,6 +22,7 @@
|
|
|
21
22
|
* файли **поза** цим графом — **непорожній** **`metadata.namespace`** (крім **кластерних** kind; див. k8s.mdc).
|
|
22
23
|
*
|
|
23
24
|
* **`kind: Ingress`** заборонено (потрібен перехід на Gateway API).
|
|
25
|
+
* **`apiVersion: autoscaling/v1`** заборонено (мігруй **HorizontalPodAutoscaler** на **`autoscaling/v2`**).
|
|
24
26
|
*
|
|
25
27
|
* Файли під **`k8s`**, де всі YAML-документи — лише **`kind: BackendConfig`**, **видаляються** автоматично.
|
|
26
28
|
* Якщо **BackendConfig** змішано з іншими ресурсами в одному файлі — перевірка завершується помилкою (розділи маніфести).
|
|
@@ -61,6 +63,22 @@
|
|
|
61
63
|
* **`hasura/graphql-engine`**, то в `data` ConfigMap обов'язково має бути ключ
|
|
62
64
|
* **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (приймається булеве `true`
|
|
63
65
|
* або рядок `"true"`, без регістрової залежності).
|
|
66
|
+
*
|
|
67
|
+
* **HPA / PDB / topologySpreadConstraints для кожного Deployment:** у каталозі з **`Deployment`** поруч
|
|
68
|
+
* обов'язкові **`hpa.yaml`** (`autoscaling/v2`, `HorizontalPodAutoscaler`, `scaleTargetRef.name` = ім'я Deployment)
|
|
69
|
+
* і **`pdb.yaml`** (`policy/v1`, `PodDisruptionBudget`, `selector.matchLabels.app` = мітка `app` Deployment).
|
|
70
|
+
* Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) — `minReplicas === 1`,
|
|
71
|
+
* `maxReplicas === 1`, `minAvailable === 0`; **прод** (решта) — `minReplicas >= 2`, `maxReplicas >= 2`,
|
|
72
|
+
* `minAvailable >= 1`. Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
|
|
73
|
+
* `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
|
|
74
|
+
* `labelSelector.matchLabels.app` рівне `spec.selector.matchLabels.app` Deployment.
|
|
75
|
+
*
|
|
76
|
+
* **Прод-оверрайди в kustomization.yaml:** для прод-оверлеїв (не dev-like) `kustomization.yaml` у своїх
|
|
77
|
+
* inline `patches[]` повинен змінювати `/spec/minReplicas` і `/spec/maxReplicas` для
|
|
78
|
+
* **HorizontalPodAutoscaler**, і `/spec/minAvailable` для **PodDisruptionBudget** — щоб прод-мінімуми
|
|
79
|
+
* з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
|
|
80
|
+
* наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
|
|
81
|
+
* яке буде оцінено під час збірки Kustomize).
|
|
64
82
|
*/
|
|
65
83
|
import { existsSync } from 'node:fs'
|
|
66
84
|
import { readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
@@ -1760,13 +1778,44 @@ function failIfIngressInDocument(rel, docIndex, rec, fail) {
|
|
|
1760
1778
|
}
|
|
1761
1779
|
|
|
1762
1780
|
/**
|
|
1763
|
-
*
|
|
1781
|
+
* Чи маніфест використовує заборонений **`apiVersion: autoscaling/v1`** (HPA).
|
|
1782
|
+
* Канон — **`autoscaling/v2`** (див. k8s.mdc).
|
|
1783
|
+
* @param {unknown} manifest корінь YAML-документа
|
|
1784
|
+
* @returns {boolean} true, якщо `apiVersion === 'autoscaling/v1'`
|
|
1785
|
+
*/
|
|
1786
|
+
export function isForbiddenAutoscalingV1Manifest(manifest) {
|
|
1787
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
1788
|
+
return false
|
|
1789
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
1790
|
+
return rec.apiVersion === 'autoscaling/v1'
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Заборонена група **`apiVersion: autoscaling/v1`** (HPA) — вимагається міграція на **`autoscaling/v2`**.
|
|
1795
|
+
* @param {string} rel відносний шлях до файлу
|
|
1796
|
+
* @param {number} docIndex 1-based індекс документа
|
|
1797
|
+
* @param {Record<string, unknown>} rec корінь маніфесту
|
|
1798
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1799
|
+
* @returns {void}
|
|
1800
|
+
*/
|
|
1801
|
+
function failIfAutoscalingV1InDocument(rel, docIndex, rec, fail) {
|
|
1802
|
+
if (!isForbiddenAutoscalingV1Manifest(rec)) {
|
|
1803
|
+
return
|
|
1804
|
+
}
|
|
1805
|
+
const kind = typeof rec.kind === 'string' ? rec.kind : '(невідомо)'
|
|
1806
|
+
fail(
|
|
1807
|
+
`${rel}: знайдено apiVersion: autoscaling/v1 (документ ${docIndex}, kind: ${kind}) — мігруй на autoscaling/v2 (див. k8s.mdc)`
|
|
1808
|
+
)
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/**
|
|
1812
|
+
* Шукає заборонені маніфести у розібраних документах: **kind: Ingress** і **apiVersion: autoscaling/v1**.
|
|
1764
1813
|
* @param {string} rel відносний шлях до файлу
|
|
1765
1814
|
* @param {string} body YAML після modeline
|
|
1766
|
-
* @param {(msg: string) => void} fail callback для помилки
|
|
1815
|
+
* @param {(msg: string) => void} fail callback для помилки
|
|
1767
1816
|
* @returns {void}
|
|
1768
1817
|
*/
|
|
1769
|
-
function
|
|
1818
|
+
function scanForbiddenManifestsInYamlDocuments(rel, body, fail) {
|
|
1770
1819
|
/** @type {import('yaml').Document[]} */
|
|
1771
1820
|
let docs
|
|
1772
1821
|
try {
|
|
@@ -1779,14 +1828,35 @@ function scanIngressInYamlDocuments(rel, body, fail) {
|
|
|
1779
1828
|
if (doc.errors.length === 0) {
|
|
1780
1829
|
const obj = doc.toJSON()
|
|
1781
1830
|
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
1782
|
-
|
|
1831
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
1832
|
+
failIfIngressInDocument(rel, di + 1, rec, fail)
|
|
1833
|
+
failIfAutoscalingV1InDocument(rel, di + 1, rec, fail)
|
|
1783
1834
|
}
|
|
1784
1835
|
}
|
|
1785
1836
|
}
|
|
1786
1837
|
}
|
|
1787
1838
|
|
|
1788
1839
|
/**
|
|
1789
|
-
*
|
|
1840
|
+
* Рекомендоване значення **`resources.requests.cpu`** за замовчуванням для підказки в повідомленнях (k8s.mdc).
|
|
1841
|
+
*/
|
|
1842
|
+
export const DEFAULT_CONTAINER_CPU_REQUEST = '0.5'
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Чи значення `resources.requests.cpu` записане у валідному вигляді:
|
|
1846
|
+
* непорожній рядок (`"500m"`, `"0.5"`) або додатне число.
|
|
1847
|
+
* @param {unknown} cpu значення поля `resources.requests.cpu`
|
|
1848
|
+
* @returns {boolean} true, якщо значення прийнятне
|
|
1849
|
+
*/
|
|
1850
|
+
function isValidCpuRequestValue(cpu) {
|
|
1851
|
+
if (typeof cpu === 'string') return cpu.trim() !== ''
|
|
1852
|
+
if (typeof cpu === 'number') return Number.isFinite(cpu) && cpu > 0
|
|
1853
|
+
return false
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Перевірка поля **`resources`** для одного контейнера **Deployment**: вимагає не лише присутність
|
|
1858
|
+
* **`resources`**, а й непорожнє **`resources.requests.cpu`** (див. k8s.mdc). Якщо конкретне
|
|
1859
|
+
* значення ще не обрано — як безпечне за замовчуванням рекомендовано **`DEFAULT_CONTAINER_CPU_REQUEST`**.
|
|
1790
1860
|
* @param {unknown} c елемент **containers[]**
|
|
1791
1861
|
* @param {string} label підпис у повідомленні
|
|
1792
1862
|
* @returns {string | null} текст порушення або null
|
|
@@ -1797,11 +1867,23 @@ function deploymentContainerResourcesViolation(c, label) {
|
|
|
1797
1867
|
}
|
|
1798
1868
|
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
1799
1869
|
if (!('resources' in cont)) {
|
|
1800
|
-
return `контейнер "${label}": відсутнє поле resources — додай resources
|
|
1870
|
+
return `контейнер "${label}": відсутнє поле resources — додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
|
|
1801
1871
|
}
|
|
1802
1872
|
const r = cont.resources
|
|
1803
1873
|
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
1804
|
-
return `контейнер "${label}": resources має бути записом у YAML
|
|
1874
|
+
return `контейнер "${label}": resources має бути записом у YAML`
|
|
1875
|
+
}
|
|
1876
|
+
const resources = /** @type {Record<string, unknown>} */ (r)
|
|
1877
|
+
const requests = resources.requests
|
|
1878
|
+
if (requests === null || requests === undefined || typeof requests !== 'object' || Array.isArray(requests)) {
|
|
1879
|
+
return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
|
|
1880
|
+
}
|
|
1881
|
+
const req = /** @type {Record<string, unknown>} */ (requests)
|
|
1882
|
+
if (!('cpu' in req)) {
|
|
1883
|
+
return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
|
|
1884
|
+
}
|
|
1885
|
+
if (!isValidCpuRequestValue(req.cpu)) {
|
|
1886
|
+
return `контейнер "${label}": resources.requests.cpu має бути непорожнім значенням (наприклад "500m" або ${DEFAULT_CONTAINER_CPU_REQUEST}) (зараз: ${JSON.stringify(req.cpu)}) (див. k8s.mdc)`
|
|
1805
1887
|
}
|
|
1806
1888
|
return null
|
|
1807
1889
|
}
|
|
@@ -3107,7 +3189,7 @@ function runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeMa
|
|
|
3107
3189
|
*/
|
|
3108
3190
|
function checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
|
|
3109
3191
|
const body = lines.join('\n')
|
|
3110
|
-
|
|
3192
|
+
scanForbiddenManifestsInYamlDocuments(rel, body, fail)
|
|
3111
3193
|
pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
|
|
3112
3194
|
runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
|
|
3113
3195
|
}
|
|
@@ -3137,7 +3219,7 @@ function checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pa
|
|
|
3137
3219
|
|
|
3138
3220
|
const body = yamlBodyAfterModeline(lines)
|
|
3139
3221
|
|
|
3140
|
-
|
|
3222
|
+
scanForbiddenManifestsInYamlDocuments(rel, body, fail)
|
|
3141
3223
|
|
|
3142
3224
|
if (schemaUrl.startsWith('file:')) {
|
|
3143
3225
|
pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
|
|
@@ -3410,6 +3492,577 @@ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs
|
|
|
3410
3492
|
}
|
|
3411
3493
|
}
|
|
3412
3494
|
|
|
3495
|
+
/**
|
|
3496
|
+
* Ім'я файлу HPA поруч із Deployment (див. k8s.mdc).
|
|
3497
|
+
*/
|
|
3498
|
+
export const HPA_FILENAME = 'hpa.yaml'
|
|
3499
|
+
|
|
3500
|
+
/**
|
|
3501
|
+
* Ім'я файлу PDB поруч із Deployment (див. k8s.mdc).
|
|
3502
|
+
*/
|
|
3503
|
+
export const PDB_FILENAME = 'pdb.yaml'
|
|
3504
|
+
|
|
3505
|
+
/**
|
|
3506
|
+
* Канонічний topologyKey для **topologySpreadConstraints** у Deployment (див. k8s.mdc).
|
|
3507
|
+
*/
|
|
3508
|
+
const TOPOLOGY_SPREAD_TOPOLOGY_KEY = 'kubernetes.io/hostname'
|
|
3509
|
+
|
|
3510
|
+
/**
|
|
3511
|
+
* Витягує сегмент каталогу після `/k8s/` у relative-шляху (перший компонент за `k8s/`).
|
|
3512
|
+
* Приклад: `app/k8s/base/deploy.yaml` → `base`; `app/k8s/tr-qa/hpa.yaml` → `tr-qa`.
|
|
3513
|
+
* @param {string} relPath відносний шлях у POSIX-форматі (через `/`)
|
|
3514
|
+
* @returns {string | null} сегмент середовища або null, якщо `/k8s/` немає в шляху
|
|
3515
|
+
*/
|
|
3516
|
+
export function k8sEnvSegmentFromRelPath(relPath) {
|
|
3517
|
+
const m = relPath.match(/(?:^|\/)k8s\/([^/]+)(?:\/|$)/u)
|
|
3518
|
+
return m ? m[1] : null
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3521
|
+
/**
|
|
3522
|
+
* Чи сегмент середовища вважається **dev-like** (м'які вимоги до HPA/PDB):
|
|
3523
|
+
* `base`, `dev`, або будь-що з суфіксом `-qa` (напр. `tr-qa`).
|
|
3524
|
+
* Решта (прод / staging / будь-який інший overlay) — прод-вимоги.
|
|
3525
|
+
* @param {string | null | undefined} segment сегмент після `/k8s/`
|
|
3526
|
+
* @returns {boolean} true для dev-like середовища
|
|
3527
|
+
*/
|
|
3528
|
+
export function isDevLikeK8sEnvSegment(segment) {
|
|
3529
|
+
if (typeof segment !== 'string' || segment === '') return false
|
|
3530
|
+
if (segment === 'base' || segment === 'dev') return true
|
|
3531
|
+
return segment.endsWith('-qa')
|
|
3532
|
+
}
|
|
3533
|
+
|
|
3534
|
+
/**
|
|
3535
|
+
* Витягує рядкове ім'я з `metadata.name` об'єкта Kubernetes.
|
|
3536
|
+
* @param {Record<string, unknown>} manifest корінь маніфесту
|
|
3537
|
+
* @returns {string | null} непорожнє ім'я або null
|
|
3538
|
+
*/
|
|
3539
|
+
function manifestMetadataName(manifest) {
|
|
3540
|
+
const meta = manifest.metadata
|
|
3541
|
+
if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) return null
|
|
3542
|
+
const n = /** @type {Record<string, unknown>} */ (meta).name
|
|
3543
|
+
return typeof n === 'string' && n.trim() !== '' ? n : null
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
/**
|
|
3547
|
+
* Витягує мітку `app` з `spec.selector.matchLabels.app` Deployment.
|
|
3548
|
+
* @param {Record<string, unknown>} deployment об'єкт Deployment
|
|
3549
|
+
* @returns {string | null} непорожнє значення `app` або null, якщо не задане
|
|
3550
|
+
*/
|
|
3551
|
+
export function deploymentAppLabel(deployment) {
|
|
3552
|
+
const spec = deployment.spec
|
|
3553
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return null
|
|
3554
|
+
const selector = /** @type {Record<string, unknown>} */ (spec).selector
|
|
3555
|
+
if (selector === null || typeof selector !== 'object' || Array.isArray(selector)) return null
|
|
3556
|
+
const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
|
|
3557
|
+
if (matchLabels === null || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) return null
|
|
3558
|
+
const app = /** @type {Record<string, unknown>} */ (matchLabels).app
|
|
3559
|
+
return typeof app === 'string' && app.trim() !== '' ? app : null
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
/**
|
|
3563
|
+
* Перетворює значення на ціле число (приймає число або числовий рядок).
|
|
3564
|
+
* @param {unknown} v значення з YAML
|
|
3565
|
+
* @returns {number | null} ціле або null, якщо не читається як ціле
|
|
3566
|
+
*/
|
|
3567
|
+
function coerceInteger(v) {
|
|
3568
|
+
if (typeof v === 'number' && Number.isInteger(v)) return v
|
|
3569
|
+
if (typeof v === 'string' && /^-?\d+$/u.test(v.trim())) return Number.parseInt(v, 10)
|
|
3570
|
+
return null
|
|
3571
|
+
}
|
|
3572
|
+
|
|
3573
|
+
/**
|
|
3574
|
+
* Перевіряє **HPA** (`autoscaling/v2`, `HorizontalPodAutoscaler`): структура й env-залежні межі
|
|
3575
|
+
* minReplicas / maxReplicas (**dev-like:** `minReplicas === 1`; **прод:** `minReplicas >= 2`, `maxReplicas >= 2`).
|
|
3576
|
+
* @param {unknown} manifest корінь YAML-документа HPA
|
|
3577
|
+
* @param {string} expectedDeployName очікуване ім'я Deployment у `scaleTargetRef.name`
|
|
3578
|
+
* @param {boolean} isDevLike чи середовище dev-like (base/dev/*-qa)
|
|
3579
|
+
* @returns {string[]} список порушень (порожній — ок)
|
|
3580
|
+
*/
|
|
3581
|
+
export function hpaManifestViolations(manifest, expectedDeployName, isDevLike) {
|
|
3582
|
+
/** @type {string[]} */
|
|
3583
|
+
const errs = []
|
|
3584
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
3585
|
+
errs.push('HPA має бути обʼєктом YAML')
|
|
3586
|
+
return errs
|
|
3587
|
+
}
|
|
3588
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
3589
|
+
if (rec.kind !== 'HorizontalPodAutoscaler') errs.push(`kind має бути HorizontalPodAutoscaler (зараз: ${JSON.stringify(rec.kind)})`)
|
|
3590
|
+
if (rec.apiVersion !== 'autoscaling/v2') errs.push(`apiVersion має бути autoscaling/v2 (зараз: ${JSON.stringify(rec.apiVersion)})`)
|
|
3591
|
+
const spec = rec.spec
|
|
3592
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
3593
|
+
errs.push('spec відсутній або некоректний')
|
|
3594
|
+
return errs
|
|
3595
|
+
}
|
|
3596
|
+
const s = /** @type {Record<string, unknown>} */ (spec)
|
|
3597
|
+
const str = s.scaleTargetRef
|
|
3598
|
+
if (str === null || str === undefined || typeof str !== 'object' || Array.isArray(str)) {
|
|
3599
|
+
errs.push('spec.scaleTargetRef відсутній')
|
|
3600
|
+
} else {
|
|
3601
|
+
const r = /** @type {Record<string, unknown>} */ (str)
|
|
3602
|
+
if (r.apiVersion !== 'apps/v1') errs.push(`spec.scaleTargetRef.apiVersion має бути apps/v1 (зараз: ${JSON.stringify(r.apiVersion)})`)
|
|
3603
|
+
if (r.kind !== 'Deployment') errs.push(`spec.scaleTargetRef.kind має бути Deployment (зараз: ${JSON.stringify(r.kind)})`)
|
|
3604
|
+
if (r.name !== expectedDeployName)
|
|
3605
|
+
errs.push(`spec.scaleTargetRef.name має бути '${expectedDeployName}' (зараз: ${JSON.stringify(r.name)})`)
|
|
3606
|
+
}
|
|
3607
|
+
const minR = coerceInteger(s.minReplicas)
|
|
3608
|
+
const maxR = coerceInteger(s.maxReplicas)
|
|
3609
|
+
if (minR === null) errs.push('spec.minReplicas має бути цілим числом')
|
|
3610
|
+
if (maxR === null) errs.push('spec.maxReplicas має бути цілим числом')
|
|
3611
|
+
if (minR !== null && maxR !== null && minR > maxR) {
|
|
3612
|
+
errs.push(`spec.minReplicas (${minR}) не може бути більше spec.maxReplicas (${maxR})`)
|
|
3613
|
+
}
|
|
3614
|
+
if (isDevLike) {
|
|
3615
|
+
if (minR !== null && minR !== 1) errs.push(`spec.minReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${minR})`)
|
|
3616
|
+
if (maxR !== null && maxR !== 1) errs.push(`spec.maxReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${maxR})`)
|
|
3617
|
+
} else {
|
|
3618
|
+
if (minR !== null && minR < 2) errs.push(`spec.minReplicas для прод середовища має бути мінімум 2 (зараз: ${minR})`)
|
|
3619
|
+
if (maxR !== null && maxR < 2) errs.push(`spec.maxReplicas для прод середовища має бути мінімум 2 (зараз: ${maxR})`)
|
|
3620
|
+
}
|
|
3621
|
+
if (!Array.isArray(s.metrics) || s.metrics.length === 0) {
|
|
3622
|
+
errs.push('spec.metrics має бути непорожнім масивом (наприклад, Resource/cpu/Utilization)')
|
|
3623
|
+
}
|
|
3624
|
+
const behavior = s.behavior
|
|
3625
|
+
if (behavior === null || behavior === undefined || typeof behavior !== 'object' || Array.isArray(behavior)) {
|
|
3626
|
+
errs.push('spec.behavior відсутній (має містити scaleUp і scaleDown)')
|
|
3627
|
+
} else {
|
|
3628
|
+
const b = /** @type {Record<string, unknown>} */ (behavior)
|
|
3629
|
+
for (const key of /** @type {const} */ (['scaleUp', 'scaleDown'])) {
|
|
3630
|
+
const v = b[key]
|
|
3631
|
+
if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) {
|
|
3632
|
+
errs.push(`spec.behavior.${key} відсутній`)
|
|
3633
|
+
continue
|
|
3634
|
+
}
|
|
3635
|
+
const policies = /** @type {Record<string, unknown>} */ (v).policies
|
|
3636
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
3637
|
+
errs.push(`spec.behavior.${key}.policies має бути непорожнім масивом`)
|
|
3638
|
+
}
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
return errs
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
/**
|
|
3645
|
+
* Перевіряє **PDB** (`policy/v1`, `PodDisruptionBudget`): структура й env-залежна межа
|
|
3646
|
+
* minAvailable (**dev-like:** `=== 0`; **прод:** `>= 1`).
|
|
3647
|
+
* @param {unknown} manifest корінь YAML-документа PDB
|
|
3648
|
+
* @param {string} expectedAppLabel очікувана мітка `app` у `selector.matchLabels`
|
|
3649
|
+
* @param {boolean} isDevLike чи середовище dev-like (base/dev/*-qa)
|
|
3650
|
+
* @returns {string[]} список порушень (порожній — ок)
|
|
3651
|
+
*/
|
|
3652
|
+
export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
|
|
3653
|
+
/** @type {string[]} */
|
|
3654
|
+
const errs = []
|
|
3655
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
|
3656
|
+
errs.push('PDB має бути обʼєктом YAML')
|
|
3657
|
+
return errs
|
|
3658
|
+
}
|
|
3659
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
3660
|
+
if (rec.kind !== 'PodDisruptionBudget') errs.push(`kind має бути PodDisruptionBudget (зараз: ${JSON.stringify(rec.kind)})`)
|
|
3661
|
+
if (rec.apiVersion !== 'policy/v1') errs.push(`apiVersion має бути policy/v1 (зараз: ${JSON.stringify(rec.apiVersion)})`)
|
|
3662
|
+
const spec = rec.spec
|
|
3663
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
|
|
3664
|
+
errs.push('spec відсутній або некоректний')
|
|
3665
|
+
return errs
|
|
3666
|
+
}
|
|
3667
|
+
const s = /** @type {Record<string, unknown>} */ (spec)
|
|
3668
|
+
const minA = coerceInteger(s.minAvailable)
|
|
3669
|
+
if (minA === null) {
|
|
3670
|
+
errs.push('spec.minAvailable має бути цілим числом')
|
|
3671
|
+
} else if (isDevLike) {
|
|
3672
|
+
if (minA !== 0) errs.push(`spec.minAvailable для dev-like (base/dev/*-qa) має бути 0 (зараз: ${minA})`)
|
|
3673
|
+
} else if (minA < 1) {
|
|
3674
|
+
errs.push(`spec.minAvailable для прод середовища має бути мінімум 1 (зараз: ${minA})`)
|
|
3675
|
+
}
|
|
3676
|
+
const selector = s.selector
|
|
3677
|
+
if (selector === null || selector === undefined || typeof selector !== 'object' || Array.isArray(selector)) {
|
|
3678
|
+
errs.push('spec.selector відсутній')
|
|
3679
|
+
} else {
|
|
3680
|
+
const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
|
|
3681
|
+
if (matchLabels === null || matchLabels === undefined || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) {
|
|
3682
|
+
errs.push('spec.selector.matchLabels відсутній')
|
|
3683
|
+
} else {
|
|
3684
|
+
const app = /** @type {Record<string, unknown>} */ (matchLabels).app
|
|
3685
|
+
if (app !== expectedAppLabel)
|
|
3686
|
+
errs.push(`spec.selector.matchLabels.app має бути '${expectedAppLabel}' (зараз: ${JSON.stringify(app)})`)
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
return errs
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
/**
|
|
3693
|
+
* Перевіряє, що Deployment має канонічний запис у **`spec.template.spec.topologySpreadConstraints`**:
|
|
3694
|
+
* `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
|
|
3695
|
+
* `labelSelector.matchLabels.app` збігається з міткою Deployment (див. k8s.mdc).
|
|
3696
|
+
* @param {unknown} manifest корінь YAML-документа Deployment
|
|
3697
|
+
* @param {string} expectedAppLabel очікувана мітка `app`
|
|
3698
|
+
* @returns {string | null} текст порушення або null
|
|
3699
|
+
*/
|
|
3700
|
+
export function deploymentTopologySpreadConstraintsViolation(manifest, expectedAppLabel) {
|
|
3701
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
3702
|
+
return null
|
|
3703
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
3704
|
+
if (rec.kind !== 'Deployment') return null
|
|
3705
|
+
const spec = rec.spec
|
|
3706
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
|
|
3707
|
+
return 'spec відсутній'
|
|
3708
|
+
const template = /** @type {Record<string, unknown>} */ (spec).template
|
|
3709
|
+
if (template === null || typeof template !== 'object' || Array.isArray(template))
|
|
3710
|
+
return 'spec.template відсутній'
|
|
3711
|
+
const podSpec = /** @type {Record<string, unknown>} */ (template).spec
|
|
3712
|
+
if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec))
|
|
3713
|
+
return 'spec.template.spec відсутній'
|
|
3714
|
+
const tsc = /** @type {Record<string, unknown>} */ (podSpec).topologySpreadConstraints
|
|
3715
|
+
if (!Array.isArray(tsc) || tsc.length === 0) {
|
|
3716
|
+
return `spec.template.spec.topologySpreadConstraints: додай запис maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
|
|
3717
|
+
}
|
|
3718
|
+
for (const item of tsc) {
|
|
3719
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
|
|
3720
|
+
const it = /** @type {Record<string, unknown>} */ (item)
|
|
3721
|
+
if (coerceInteger(it.maxSkew) !== 1) continue
|
|
3722
|
+
if (it.topologyKey !== TOPOLOGY_SPREAD_TOPOLOGY_KEY) continue
|
|
3723
|
+
if (it.whenUnsatisfiable !== 'ScheduleAnyway') continue
|
|
3724
|
+
const ls = it.labelSelector
|
|
3725
|
+
if (ls === null || typeof ls !== 'object' || Array.isArray(ls)) continue
|
|
3726
|
+
const ml = /** @type {Record<string, unknown>} */ (ls).matchLabels
|
|
3727
|
+
if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) continue
|
|
3728
|
+
if (/** @type {Record<string, unknown>} */ (ml).app === expectedAppLabel) {
|
|
3729
|
+
return null
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
return `spec.template.spec.topologySpreadConstraints: бракує запису maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
|
|
3733
|
+
}
|
|
3734
|
+
|
|
3735
|
+
/**
|
|
3736
|
+
* Збирає всі документи з **k8s**-yaml за заданим `kind` у каталозі.
|
|
3737
|
+
* @param {string} dirPath абсолютний шлях до каталогу
|
|
3738
|
+
* @param {string} kind очікуваний `kind` (наприклад, `HorizontalPodAutoscaler`)
|
|
3739
|
+
* @param {string} [filenameFilter] фільтр за basename (наприклад, `hpa.yaml`); якщо заданий — лише цей файл
|
|
3740
|
+
* @returns {Promise<Record<string, unknown>[]>} список знайдених документів
|
|
3741
|
+
*/
|
|
3742
|
+
async function readDocsByKindInDir(dirPath, kind, filenameFilter) {
|
|
3743
|
+
/** @type {Record<string, unknown>[]} */
|
|
3744
|
+
const out = []
|
|
3745
|
+
let entries
|
|
3746
|
+
try {
|
|
3747
|
+
entries = await readdir(dirPath)
|
|
3748
|
+
} catch {
|
|
3749
|
+
return out
|
|
3750
|
+
}
|
|
3751
|
+
for (const entry of entries) {
|
|
3752
|
+
if (filenameFilter !== undefined) {
|
|
3753
|
+
if (entry !== filenameFilter) continue
|
|
3754
|
+
} else if (!K8S_YAML_EXT_RE.test(entry)) {
|
|
3755
|
+
continue
|
|
3756
|
+
}
|
|
3757
|
+
let raw
|
|
3758
|
+
try {
|
|
3759
|
+
raw = await readFile(join(dirPath, entry), 'utf8')
|
|
3760
|
+
} catch {
|
|
3761
|
+
continue
|
|
3762
|
+
}
|
|
3763
|
+
let docs
|
|
3764
|
+
try {
|
|
3765
|
+
docs = parseAllDocuments(raw)
|
|
3766
|
+
} catch {
|
|
3767
|
+
continue
|
|
3768
|
+
}
|
|
3769
|
+
for (const doc of docs) {
|
|
3770
|
+
if (doc.errors.length > 0) continue
|
|
3771
|
+
const obj = doc.toJSON()
|
|
3772
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3773
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
3774
|
+
if (rec.kind === kind) out.push(rec)
|
|
3775
|
+
}
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
return out
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
/**
|
|
3782
|
+
* Збирає шляхи **JSON Pointer**, які змінює один inline `patch` у **`patches[]`** kustomization.yaml.
|
|
3783
|
+
* Підтримка двох форматів:
|
|
3784
|
+
* — **JSON6902** (масив операцій): беремо `path` кожної операції (через `collectJson6902OperationsFromPatchText`).
|
|
3785
|
+
* — **Strategic Merge** (YAML-обʼєкт): плоскі шляхи до всіх листових полів (наприклад
|
|
3786
|
+
* `spec.minReplicas: 2` → `/spec/minReplicas`). Проміжні обʼєкти не вважаються «зміненими» — лише листки.
|
|
3787
|
+
* @param {string} patchText вміст поля `patch`
|
|
3788
|
+
* @returns {Set<string>} шляхи JSON Pointer (наприклад `/spec/minReplicas`)
|
|
3789
|
+
*/
|
|
3790
|
+
export function kustomizePatchModifiedPaths(patchText) {
|
|
3791
|
+
/** @type {Set<string>} */
|
|
3792
|
+
const out = new Set()
|
|
3793
|
+
const t = typeof patchText === 'string' ? patchText.trim() : ''
|
|
3794
|
+
if (t === '') return out
|
|
3795
|
+
const ops = collectJson6902OperationsFromPatchText(patchText)
|
|
3796
|
+
if (ops.length > 0) {
|
|
3797
|
+
for (const { path } of ops) {
|
|
3798
|
+
if (path) out.add(path)
|
|
3799
|
+
}
|
|
3800
|
+
return out
|
|
3801
|
+
}
|
|
3802
|
+
let parsed
|
|
3803
|
+
try {
|
|
3804
|
+
for (const d of parseAllDocuments(t)) {
|
|
3805
|
+
if (d.errors.length === 0) {
|
|
3806
|
+
parsed = d.toJSON()
|
|
3807
|
+
break
|
|
3808
|
+
}
|
|
3809
|
+
}
|
|
3810
|
+
} catch {
|
|
3811
|
+
return out
|
|
3812
|
+
}
|
|
3813
|
+
if (parsed === null || parsed === undefined || typeof parsed !== 'object' || Array.isArray(parsed)) return out
|
|
3814
|
+
/**
|
|
3815
|
+
* Рекурсивний обхід: шлях додаємо лише для листків (скаляр / масив).
|
|
3816
|
+
* @param {Record<string, unknown>} obj
|
|
3817
|
+
* @param {string} prefix
|
|
3818
|
+
*/
|
|
3819
|
+
const walk = (obj, prefix) => {
|
|
3820
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
3821
|
+
const p = `${prefix}/${k}`
|
|
3822
|
+
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
|
|
3823
|
+
walk(/** @type {Record<string, unknown>} */ (v), p)
|
|
3824
|
+
} else {
|
|
3825
|
+
out.add(p)
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
}
|
|
3829
|
+
walk(/** @type {Record<string, unknown>} */ (parsed), '')
|
|
3830
|
+
return out
|
|
3831
|
+
}
|
|
3832
|
+
|
|
3833
|
+
/**
|
|
3834
|
+
* Читає `kind` з inline **`patch`** у форматі Strategic Merge (для випадків, коли **`target.kind`** не заданий).
|
|
3835
|
+
* @param {string} patchText вміст поля `patch`
|
|
3836
|
+
* @returns {string | null} значення `kind` першого документа або null
|
|
3837
|
+
*/
|
|
3838
|
+
function strategicMergePatchKind(patchText) {
|
|
3839
|
+
const t = typeof patchText === 'string' ? patchText.trim() : ''
|
|
3840
|
+
if (t === '') return null
|
|
3841
|
+
try {
|
|
3842
|
+
for (const d of parseAllDocuments(t)) {
|
|
3843
|
+
if (d.errors.length === 0) {
|
|
3844
|
+
const obj = d.toJSON()
|
|
3845
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3846
|
+
const k = /** @type {Record<string, unknown>} */ (obj).kind
|
|
3847
|
+
if (typeof k === 'string' && k !== '') return k
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
} catch {
|
|
3852
|
+
return null
|
|
3853
|
+
}
|
|
3854
|
+
return null
|
|
3855
|
+
}
|
|
3856
|
+
|
|
3857
|
+
/**
|
|
3858
|
+
* Збирає шляхи, змінені всіма inline `patches[]` у kustomization, згрупованими за `kind` цілі.
|
|
3859
|
+
* `kind` визначається з `target.kind` (канон) або, якщо відсутній — з `kind:` у тілі Strategic Merge patch.
|
|
3860
|
+
* @param {Record<string, unknown>} kust об'єкт kustomization.yaml
|
|
3861
|
+
* @returns {Map<string, Set<string>>} `kind` → шляхи JSON Pointer, які overrides змінюють
|
|
3862
|
+
*/
|
|
3863
|
+
export function kustomizationPatchPathsByTargetKind(kust) {
|
|
3864
|
+
/** @type {Map<string, Set<string>>} */
|
|
3865
|
+
const byKind = new Map()
|
|
3866
|
+
const patches = kust.patches
|
|
3867
|
+
if (!Array.isArray(patches)) return byKind
|
|
3868
|
+
for (const p of patches) {
|
|
3869
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
|
|
3870
|
+
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
3871
|
+
if (typeof pr.patch !== 'string') continue
|
|
3872
|
+
/** @type {string | null} */
|
|
3873
|
+
let kind = null
|
|
3874
|
+
const target = pr.target
|
|
3875
|
+
if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
|
|
3876
|
+
const tk = /** @type {Record<string, unknown>} */ (target).kind
|
|
3877
|
+
if (typeof tk === 'string' && tk !== '') kind = tk
|
|
3878
|
+
}
|
|
3879
|
+
if (kind === null) kind = strategicMergePatchKind(pr.patch)
|
|
3880
|
+
if (kind === null) continue
|
|
3881
|
+
const paths = kustomizePatchModifiedPaths(pr.patch)
|
|
3882
|
+
if (!byKind.has(kind)) byKind.set(kind, new Set())
|
|
3883
|
+
const set = byKind.get(kind)
|
|
3884
|
+
for (const x of paths) set.add(x)
|
|
3885
|
+
}
|
|
3886
|
+
return byKind
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
/**
|
|
3890
|
+
* Для прод kustomization.yaml вимагає патчі, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
|
|
3891
|
+
* на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**. Не застосовується
|
|
3892
|
+
* до dev-like (base / dev / *-qa) — там ці значення беруть з base (див. k8s.mdc).
|
|
3893
|
+
* @param {string} root корінь репозиторію
|
|
3894
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
3895
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
3896
|
+
* @param {(msg: string) => void} passFn callback при успіху
|
|
3897
|
+
*/
|
|
3898
|
+
async function validateProdKustomizationOverrides(root, yamlFilesAbs, fail, passFn) {
|
|
3899
|
+
const kustFiles = yamlFilesAbs.filter(abs => basename(abs) === 'kustomization.yaml')
|
|
3900
|
+
for (const kustAbs of kustFiles) {
|
|
3901
|
+
const rel = relative(root, kustAbs).replaceAll('\\', '/')
|
|
3902
|
+
const segment = k8sEnvSegmentFromRelPath(rel)
|
|
3903
|
+
if (segment === null || isDevLikeK8sEnvSegment(segment)) continue
|
|
3904
|
+
let raw
|
|
3905
|
+
try {
|
|
3906
|
+
raw = await readFile(kustAbs, 'utf8')
|
|
3907
|
+
} catch {
|
|
3908
|
+
continue
|
|
3909
|
+
}
|
|
3910
|
+
/** @type {Record<string, unknown> | undefined} */
|
|
3911
|
+
let kust
|
|
3912
|
+
try {
|
|
3913
|
+
for (const doc of parseAllDocuments(raw)) {
|
|
3914
|
+
if (doc.errors.length === 0) {
|
|
3915
|
+
const obj = doc.toJSON()
|
|
3916
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3917
|
+
kust = /** @type {Record<string, unknown>} */ (obj)
|
|
3918
|
+
break
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
} catch {
|
|
3923
|
+
continue
|
|
3924
|
+
}
|
|
3925
|
+
if (kust === undefined) continue
|
|
3926
|
+
const byKind = kustomizationPatchPathsByTargetKind(kust)
|
|
3927
|
+
const hpaPaths = byKind.get('HorizontalPodAutoscaler') ?? new Set()
|
|
3928
|
+
const pdbPaths = byKind.get('PodDisruptionBudget') ?? new Set()
|
|
3929
|
+
let ok = true
|
|
3930
|
+
if (!hpaPaths.has('/spec/minReplicas')) {
|
|
3931
|
+
fail(
|
|
3932
|
+
`${rel}: прод-оверлей має перевизначати spec.minReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
|
|
3933
|
+
)
|
|
3934
|
+
ok = false
|
|
3935
|
+
}
|
|
3936
|
+
if (!hpaPaths.has('/spec/maxReplicas')) {
|
|
3937
|
+
fail(
|
|
3938
|
+
`${rel}: прод-оверлей має перевизначати spec.maxReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
|
|
3939
|
+
)
|
|
3940
|
+
ok = false
|
|
3941
|
+
}
|
|
3942
|
+
if (!pdbPaths.has('/spec/minAvailable')) {
|
|
3943
|
+
fail(
|
|
3944
|
+
`${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
|
|
3945
|
+
)
|
|
3946
|
+
ok = false
|
|
3947
|
+
}
|
|
3948
|
+
if (ok) {
|
|
3949
|
+
passFn(`${rel}: прод-оверрайди HPA minReplicas/maxReplicas і PDB minAvailable присутні (k8s.mdc)`)
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
/**
|
|
3955
|
+
* Для кожного **Deployment** під `k8s/` перевіряє: у тому ж каталозі повинні бути
|
|
3956
|
+
* `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`), а сам Deployment
|
|
3957
|
+
* повинен мати канонічні **topologySpreadConstraints**. Env-залежні межі (`minReplicas`,
|
|
3958
|
+
* `minAvailable`) — за сегментом після `/k8s/`: `base` / `dev` / `*-qa` = dev-like, решта — прод.
|
|
3959
|
+
* @param {string} root корінь репозиторію
|
|
3960
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
3961
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
3962
|
+
* @param {(msg: string) => void} passFn callback при успіху
|
|
3963
|
+
*/
|
|
3964
|
+
async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, passFn) {
|
|
3965
|
+
/** @type {Set<string>} */
|
|
3966
|
+
const seenDirs = new Set()
|
|
3967
|
+
for (const abs of yamlFilesAbs) {
|
|
3968
|
+
const dir = dirname(abs)
|
|
3969
|
+
if (seenDirs.has(dir)) continue
|
|
3970
|
+
let raw
|
|
3971
|
+
try {
|
|
3972
|
+
raw = await readFile(abs, 'utf8')
|
|
3973
|
+
} catch {
|
|
3974
|
+
continue
|
|
3975
|
+
}
|
|
3976
|
+
let docs
|
|
3977
|
+
try {
|
|
3978
|
+
docs = parseAllDocuments(raw)
|
|
3979
|
+
} catch {
|
|
3980
|
+
continue
|
|
3981
|
+
}
|
|
3982
|
+
/** @type {Record<string, unknown>[]} */
|
|
3983
|
+
const deployments = []
|
|
3984
|
+
for (const doc of docs) {
|
|
3985
|
+
if (doc.errors.length > 0) continue
|
|
3986
|
+
const obj = doc.toJSON()
|
|
3987
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
3988
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
3989
|
+
if (rec.kind === 'Deployment') deployments.push(rec)
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
if (deployments.length === 0) continue
|
|
3993
|
+
seenDirs.add(dir)
|
|
3994
|
+
const relDir = relative(root, dir).replaceAll('\\', '/')
|
|
3995
|
+
const segment = k8sEnvSegmentFromRelPath(relDir + '/')
|
|
3996
|
+
const isDevLike = isDevLikeK8sEnvSegment(segment)
|
|
3997
|
+
const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
|
|
3998
|
+
const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
|
|
3999
|
+
for (const deployment of deployments) {
|
|
4000
|
+
const deployName = manifestMetadataName(deployment)
|
|
4001
|
+
const appLabel = deploymentAppLabel(deployment)
|
|
4002
|
+
const deployRel = relDir === '' ? '.' : relDir
|
|
4003
|
+
if (deployName === null) {
|
|
4004
|
+
fail(`${deployRel}: Deployment без metadata.name — не можу перевірити HPA/PDB (k8s.mdc)`)
|
|
4005
|
+
continue
|
|
4006
|
+
}
|
|
4007
|
+
if (appLabel === null) {
|
|
4008
|
+
fail(`${deployRel}: Deployment '${deployName}' без spec.selector.matchLabels.app — додай мітку (k8s.mdc)`)
|
|
4009
|
+
continue
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
const tscViolation = deploymentTopologySpreadConstraintsViolation(deployment, appLabel)
|
|
4013
|
+
if (tscViolation !== null) {
|
|
4014
|
+
fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
|
|
4015
|
+
} else {
|
|
4016
|
+
passFn(`${deployRel}: Deployment '${deployName}' має канонічні topologySpreadConstraints (k8s.mdc)`)
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
const hpaRel = `${deployRel}/${HPA_FILENAME}`
|
|
4020
|
+
const matchedHpa = hpaDocs.find(h => {
|
|
4021
|
+
const spec = h.spec
|
|
4022
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
|
|
4023
|
+
const str = /** @type {Record<string, unknown>} */ (spec).scaleTargetRef
|
|
4024
|
+
if (str === null || typeof str !== 'object' || Array.isArray(str)) return false
|
|
4025
|
+
return /** @type {Record<string, unknown>} */ (str).name === deployName
|
|
4026
|
+
})
|
|
4027
|
+
if (matchedHpa === undefined) {
|
|
4028
|
+
fail(
|
|
4029
|
+
`${hpaRel}: відсутній або не знайдено HPA зі scaleTargetRef.name='${deployName}' поруч із Deployment (k8s.mdc)`
|
|
4030
|
+
)
|
|
4031
|
+
} else {
|
|
4032
|
+
const hpaErrs = hpaManifestViolations(matchedHpa, deployName, isDevLike)
|
|
4033
|
+
if (hpaErrs.length === 0) {
|
|
4034
|
+
passFn(`${hpaRel}: HPA для Deployment '${deployName}' валідний (k8s.mdc)`)
|
|
4035
|
+
} else {
|
|
4036
|
+
for (const e of hpaErrs) fail(`${hpaRel}: ${e} (k8s.mdc)`)
|
|
4037
|
+
}
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
const pdbRel = `${deployRel}/${PDB_FILENAME}`
|
|
4041
|
+
const matchedPdb = pdbDocs.find(p => {
|
|
4042
|
+
const spec = p.spec
|
|
4043
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
|
|
4044
|
+
const selector = /** @type {Record<string, unknown>} */ (spec).selector
|
|
4045
|
+
if (selector === null || typeof selector !== 'object' || Array.isArray(selector)) return false
|
|
4046
|
+
const ml = /** @type {Record<string, unknown>} */ (selector).matchLabels
|
|
4047
|
+
if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) return false
|
|
4048
|
+
return /** @type {Record<string, unknown>} */ (ml).app === appLabel
|
|
4049
|
+
})
|
|
4050
|
+
if (matchedPdb === undefined) {
|
|
4051
|
+
fail(
|
|
4052
|
+
`${pdbRel}: відсутній або не знайдено PDB зі selector.matchLabels.app='${appLabel}' поруч із Deployment (k8s.mdc)`
|
|
4053
|
+
)
|
|
4054
|
+
} else {
|
|
4055
|
+
const pdbErrs = pdbManifestViolations(matchedPdb, appLabel, isDevLike)
|
|
4056
|
+
if (pdbErrs.length === 0) {
|
|
4057
|
+
passFn(`${pdbRel}: PDB для Deployment '${deployName}' валідний (k8s.mdc)`)
|
|
4058
|
+
} else {
|
|
4059
|
+
for (const e of pdbErrs) fail(`${pdbRel}: ${e} (k8s.mdc)`)
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
|
|
3413
4066
|
/**
|
|
3414
4067
|
* Перевіряє відповідність проєкту правилам k8s.mdc.
|
|
3415
4068
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -3455,5 +4108,9 @@ export async function check() {
|
|
|
3455
4108
|
|
|
3456
4109
|
await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
|
|
3457
4110
|
|
|
4111
|
+
await validateDeploymentHpaPdbAndTopology(root, yamlFiles, fail, pass)
|
|
4112
|
+
|
|
4113
|
+
await validateProdKustomizationOverrides(root, yamlFiles, fail, pass)
|
|
4114
|
+
|
|
3458
4115
|
return reporter.getExitCode()
|
|
3459
4116
|
}
|