@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 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`** / **`initContainers`** має бути **`resources`**; якщо лімітів ще немає мінімум порожній запис:
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`** перевіряє наявність **`resources`** у кожному документі **Deployment** під **`k8s`**. Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.114",
3
+ "version": "1.8.117",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
- * порожній **`{}`**). Поле **`imagePullPolicy`** не перевіряється діють типові правила Kubernetes
12
- * (`:latest` або коли тег не вказано → **Always**, інші теги → **IfNotPresent**). Якщо серед **`containers`** /
13
- * **`initContainers`** є образ **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`**
14
- * (див. k8s.mdc).
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
- * Шукає **Ingress** у розібраних документах; реєструє порушення.
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 для помилки (Ingress)
1815
+ * @param {(msg: string) => void} fail callback для помилки
1767
1816
  * @returns {void}
1768
1817
  */
1769
- function scanIngressInYamlDocuments(rel, body, fail) {
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
- failIfIngressInDocument(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
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
- * Перевірка поля **resources** для одного контейнера **Deployment**.
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: {} (див. k8s.mdc)`
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 (наприклад порожній: resources: {})`
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
- scanIngressInYamlDocuments(rel, body, fail)
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
- scanIngressInYamlDocuments(rel, body, fail)
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
  }