@nitra/cursor 1.8.213 → 1.8.216

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/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.216] - 2026-05-09
8
+
9
+ ### Changed
10
+
11
+ - **k8s / check-k8s:** орієнтир **`DEFAULT_CONTAINER_MEMORY_REQUEST`** поза base — **`512Mi`** (замість **`512`**).
12
+
13
+ ## [1.8.215] - 2026-05-09
14
+
15
+ ### Changed
16
+
17
+ - **k8s / check-k8s:** канон **`resources.requests.memory`** у шарі **`…/k8s/…/base/…`** — **`128Mi`** (замість **`128`**, щоб відповідати Quantity у Kubernetes); приймається **`Mi`** без урахування регістру.
18
+
19
+ ## [1.8.214] - 2026-05-09
20
+
21
+ ### Fixed
22
+
23
+ - **k8s / check-k8s:** конвертація image-replace patches → `images:` падала з `byPatch.keys(...).toSorted is not a function`, бо `Map.keys()` повертає ітератор без `toSorted`. Тепер ключі спершу матеріалізуються у масив (`[...byPatch.keys()].toSorted(...)`).
24
+
25
+ ### Changed
26
+
27
+ - **k8s / check-k8s:** у шарі **`…/k8s/…/base/…`** для **Deployment** жорстко **`resources.requests.cpu: '0.02'`** та **`memory: '128'`**; поза base обов’язкові **cpu** і **memory** (орієнтир **`0.5`** / **`512`** у підказках).
28
+ - **k8s / check-k8s:** заборона **`hpa.yaml`** у каталозі **`…/base/`**; якщо HPA є в дереві base — вимагається strategic-merge **`$patch: delete`** для **HorizontalPodAutoscaler** у **`base/kustomization.yaml`**.
29
+ - **k8s / check-k8s:** прод-оверлей вимагає patches на **HPA** (`minReplicas`/`maxReplicas`) лише якщо успадковане base **не** видаляє HPA через delete-patch; **PDB** **`minAvailable`** — якщо в base є PDB.
30
+ - **k8s.mdc:** оновлено правила та приклади під цю модель.
31
+
7
32
  ## [1.8.213] - 2026-05-09
8
33
 
9
34
  ### Added
package/mdc/k8s.mdc CHANGED
@@ -96,19 +96,51 @@ jobs:
96
96
  run: bun run lint-k8s
97
97
  ```
98
98
 
99
- ## Deployment: `resources.requests.cpu`
99
+ ## Deployment: `resources.requests` (CPU і memory)
100
100
 
101
- У **`Deployment`** у кожному **`containers`** має бути **`resources.requests.cpu`** непорожнє значення (наприклад **`"500m"`**, **`"100m"`** або число на кшталт **`0.5`**). Це гарантує, що Kubernetes планує pod з мінімальним бюджетом CPU і коректно працює з HPA (CPU target %).
101
+ У кожному контейнері **`Deployment`** обов’язкові **`resources.requests.cpu`** і **`resources.requests.memory`** (непорожні скаляри Kubernetes Quantity).
102
102
 
103
- Якщо для сервісу ще не обрано конкретне значення — став **`"0.5"`** як безпечний мінімум:
103
+ ### Шар **`…/k8s/…/base/…`** (dev / щільний packing)
104
+
105
+ У **всіх** `Deployment` у файлах під **`…/k8s/…/base/…`** значення **жорстко фіксовані** (для **cpu** допускається число **`0.02`** у YAML):
106
+
107
+ ```yaml
108
+ resources:
109
+ requests:
110
+ cpu: '0.02'
111
+ memory: '128Mi'
112
+ ```
113
+
114
+ **HPA у base не тримаємо** у локальному **`hpa.yaml`** поруч із `Deployment`. Якщо **HorizontalPodAutoscaler** потрапляє в дерево Kustomize з **`resources`** / **`components`** / **`bases`**, у **`…/base/kustomization.yaml`** додай strategic-merge patch з **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`** (і **`metadata.name`**, що збігається з ресурсом, який треба прибрати). **`check k8s`** перевіряє наявність такого patch, коли в дереві base одночасно є **Deployment** і **HPA**.
115
+
116
+ ### Поза base (оверлеї, окремі каталоги)
117
+
118
+ Якщо ще не підібрано власні ліміти під сервіс, орієнтир для **`requests`**:
104
119
 
105
120
  ```yaml
106
121
  resources:
107
122
  requests:
108
- cpu: '0.5' # довільне значення; 0.5 — дефолт, якщо нічого специфічного ще не обрано
123
+ cpu: '0.5'
124
+ memory: '512Mi'
125
+ ```
126
+
127
+ У прод-оверлеях підіймай **`cpu` / `memory`** до реального споживання через **`patches`** або окремі фрагменти маніфестів. **`check k8s`** не вимагає саме **`0.5` / `512Mi`** поза base — лише непорожні **`requests.cpu`** і **`requests.memory`**.
128
+
129
+ ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
130
+ patches:
131
+ - target:
132
+ kind: Deployment
133
+ name: backend-api
134
+ patch: |-
135
+ - op: replace
136
+ path: /spec/template/spec/containers/0/resources/requests/cpu
137
+ value: '500m'
138
+ - op: replace
139
+ path: /spec/template/spec/containers/0/resources/requests/memory
140
+ value: 1Gi
109
141
  ```
110
142
 
111
- **`check k8s`** перевіряє присутність і непорожність **`resources.requests.cpu`** у кожному документі **Deployment** під **`k8s`**. Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
143
+ Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
112
144
 
113
145
  Образ **`hasura/graphql-engine`**: дозволений лише канонічний тег із константи **`HASURA_GRAPHQL_ENGINE_IMAGE`** у **`check-k8s.mjs`** (допускається префікс **`docker.io/`**); решта — помилка **check k8s**.
114
146
 
@@ -341,15 +373,17 @@ images:
341
373
 
342
374
  **`check k8s`:** заборонено **`kind: Ingress`**.
343
375
 
344
- ## Deployment: обов'язкові `hpa.yaml`, `pdb.yaml`, `topologySpreadConstraints`
376
+ ## Deployment: `pdb.yaml`, `topologySpreadConstraints`, HPA поза dev-base
345
377
 
346
- Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у **тому ж каталозі** мають бути **`hpa.yaml`** (HPA) і **`pdb.yaml`** (PDB), а сам Deployment — канонічні **`spec.template.spec.topologySpreadConstraints`**. Інші workload-и (**CronJob**, **Job** тощо) або каталоги без шару **`base`** цими вимогами не охоплюються **`check k8s`** їх не змушує додавати HPA/PDB поруч. Скрипт звіряє прив’язку за іменами:
378
+ Для **кожного** `kind: Deployment` у каталозі **`…/k8s/…/base/`** (у будь-якому файлі `.yaml`, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у **тому ж каталозі** має бути **`pdb.yaml`** (PDB), а сам Deployment — канонічні **`spec.template.spec.topologySpreadConstraints`**. Локальний **`hpa.yaml`** у каталозі **`…/base/`** **заборонено** для dev HPA з дерева Kustomize прибирається через **`$patch: delete`** у **`base/kustomization.yaml`** (див. розділ про **`resources.requests`** вище).
347
379
 
348
- - **`hpa.yaml`** `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
380
+ У **не-base** оверлеях поруч із `Deployment` лишається звична схема: окремий **`hpa.yaml`**, якщо потрібен HPA для цього середовища. **`check k8s`** звіряє прив’язку за іменами:
381
+
382
+ - **`hpa.yaml`** (поза **`…/base/`**) — `autoscaling/v2`, `HorizontalPodAutoscaler`, `spec.scaleTargetRef.name` **= `metadata.name`** Deployment.
349
383
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
350
384
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
351
385
 
352
- **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є хоча б один **`Deployment`** у YAML під **`…/k8s/…/base/`**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться такий Deployment. Перевіряє **`check-k8s.mjs`**.
386
+ **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є хоча б один **`Deployment`** у YAML під **`…/k8s/…/base/`**. Якщо після збору в дереві є і **Deployment**, і **HPA**, **base** `kustomization.yaml` має містити strategic-merge видалення **HorizontalPodAutoscaler** (`$patch: delete`). У `kustomization.yaml` overlay, який підключає цей `base`, не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться такий Deployment. Перевіряє **`check-k8s.mjs`**.
353
387
 
354
388
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
355
389
 
@@ -359,7 +393,7 @@ images:
359
393
 
360
394
  **Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
361
395
 
362
- - HPA: `minReplicas` — рівно **1**, `maxReplicas` — рівно **1** (у деві не масштабуємо).
396
+ - У **зібраному** маніфесті (після Kustomize), якщо лишився **HPA**: `minReplicas` — рівно **1**, `maxReplicas` — рівно **1**.
363
397
  - PDB: `minAvailable` — рівно **0**.
364
398
 
365
399
  **Прод-середовища** — усе інше (будь-який overlay без суфікса `-qa`):
@@ -369,12 +403,12 @@ images:
369
403
 
370
404
  ### Прод-оверрайди у `kustomization.yaml`
371
405
 
372
- Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-накладенні `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення **лише якщо** цей оверлей наслідує base-дерево, де є **Deployment** і **HPA/PDB** (тобто base реально дає dev-like HPA/PDB, які треба підняти в проді):
406
+ У прод-накладенні `kustomization.yaml` у `patches[]` **обов’язкові** перевизначення залежно від того, що дає успадковане **base**-дерево:
373
407
 
374
- - для `HorizontalPodAutoscaler`: `spec.minReplicas` **і** `spec.maxReplicas` (щоб у проді вийшло2).
375
- - для `PodDisruptionBudget`: `spec.minAvailable` (щоб у проді вийшло ≥1).
408
+ - **`PodDisruptionBudget`**: `spec.minAvailable` якщо в base-дереві є **Deployment** і **PDB** (типово dev-like `0` прод 1).
409
+ - **`HorizontalPodAutoscaler`**: `spec.minReplicas` **і** `spec.maxReplicas` **лише якщо** в base-дереві є **HPA**, який **не** прибирається в **base** через **`$patch: delete`**. Якщо base видаляє HPA для dev, у прод-оверлеї додай **HPA** окремим YAML у **`resources`** (або patches на ресурс, що з’являється з іншого шару) з прод-мінімумами.
376
410
 
377
- Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** перевизначення відповідного поля. Конкретне значення має задовольняти прод-мінімуми — це видно у вмісті patch і остаточно матеріалізується під час збірки Kustomize.
411
+ Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** відповідних JSON Pointer-ів у **`patches[]`**.
378
412
 
379
413
  ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
380
414
  patches:
@@ -397,9 +431,30 @@ patches:
397
431
  value: 1
398
432
  ```
399
433
 
434
+ ### Приклад: прибрати HPA у `base/kustomization.yaml`
435
+
436
+ Якщо **HPA** підключений з **`../component`** (або іншого шляху в **`resources`**), а у dev він не потрібен:
437
+
438
+ ```yaml title="k8s/base/kustomization.yaml (фрагмент)"
439
+ resources:
440
+ - ../component
441
+ - deploy.yaml
442
+ - pdb.yaml
443
+ patches:
444
+ - target:
445
+ kind: HorizontalPodAutoscaler
446
+ name: backend-api
447
+ patch: |-
448
+ $patch: delete
449
+ apiVersion: autoscaling/v2
450
+ kind: HorizontalPodAutoscaler
451
+ metadata:
452
+ name: backend-api
453
+ ```
454
+
400
455
  ### Приклади
401
456
 
402
- ```yaml title="k8s/base/hpa.yaml"
457
+ ```yaml title="k8s/prod/hpa.yaml (або компонент з HPA — не в …/base/ локальному hpa.yaml)"
403
458
  # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
404
459
  apiVersion: autoscaling/v2
405
460
  kind: HorizontalPodAutoscaler
@@ -410,8 +465,8 @@ spec:
410
465
  apiVersion: apps/v1
411
466
  kind: Deployment
412
467
  name: backend-api
413
- minReplicas: 1 # dev-like; прод-накладення перевизначають на ≥ 2
414
- maxReplicas: 1 # dev-like; прод-накладення перевизначають на ≥ 2
468
+ minReplicas: 2
469
+ maxReplicas: 10
415
470
  metrics:
416
471
  - type: Resource
417
472
  resource:
@@ -456,6 +511,13 @@ spec:
456
511
  spec:
457
512
  template:
458
513
  spec:
514
+ containers:
515
+ - name: backend-api
516
+ image: example.registry/backend-api:tag
517
+ resources:
518
+ requests:
519
+ cpu: '0.02'
520
+ memory: '128Mi'
459
521
  topologySpreadConstraints:
460
522
  - maxSkew: 1
461
523
  topologyKey: kubernetes.io/hostname
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.213",
3
+ "version": "1.8.216",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,9 +7,11 @@
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
- * **`resources.requests.cpu`** (рядок на кшталт **`"500m"`** або число; якщо значення ще не обрано —
12
- * рекомендоване за замовчуванням **`DEFAULT_CONTAINER_CPU_REQUEST`** = **`"0.5"`**). Поле **`imagePullPolicy`**
10
+ * **`spec.template.spec.containers[]`** має бути **`resources.requests.cpu`** і **`resources.requests.memory`**
11
+ * (непорожні скаляри). У шарі **`…/k8s/…/base/…`** значення жорстко **`cpu: '0.02'`**, **`memory: '128Mi'`**
12
+ * (для **cpu** допускається число **`0.02`**). Поза base, якщо ще не підібрано власні
13
+ * ліміти — орієнтир **`DEFAULT_CONTAINER_CPU_REQUEST`** = **`"0.5"`**, **`DEFAULT_CONTAINER_MEMORY_REQUEST`**
14
+ * = **`"512Mi"`**. Поле **`imagePullPolicy`**
13
15
  * не перевіряється — діють типові правила Kubernetes (`:latest` або коли тег не вказано → **Always**,
14
16
  * інші теги → **IfNotPresent**). Якщо серед **`containers`** / **`initContainers`** є образ
15
17
  * **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`** (див. k8s.mdc).
@@ -82,21 +84,20 @@
82
84
  * або рядок `"true"`, без регістрової залежності).
83
85
  *
84
86
  * **HPA / PDB / topologySpreadConstraints:** для кожного **`Deployment`** у шарі **`…/k8s/…/base/`** (будь-який
85
- * `.yaml` у цьому каталозі, наприклад **`deploy.yaml`**, **`deployment.yaml`**) у тому ж каталозі поруч обов'язкові
86
- * **`hpa.yaml`**, **`pdb.yaml`** та канонічні **topologySpreadConstraints**. Workload-и без Deployment (**CronJob**
87
- * тощо) та каталоги поза **`…/base/`** цим блоком не охоплюються.
88
- * Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) `minReplicas === 1`,
89
- * `maxReplicas === 1`, `minAvailable === 0`; **прод** (решта)`minReplicas >= 2`, `maxReplicas >= 2`,
90
- * `minAvailable >= 1`. Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
91
- * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
92
- * `labelSelector.matchLabels.app` рівне `spec.selector.matchLabels.app` Deployment.
87
+ * `.yaml` у цьому каталозі) поруч обов’язкові **`pdb.yaml`** і канонічні **topologySpreadConstraints**. У каталозі
88
+ * **`…/base/`** не тримай **`hpa.yaml`** для dev HPA прибирається з дерева Kustomize через strategic-merge patch
89
+ * з **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`** у **`base/kustomization.yaml`** (якщо HPA тягнеться з
90
+ * `resources` / `components` / `bases`). Якщо в дереві base є і **Deployment**, і **HorizontalPodAutoscaler**, такий
91
+ * patch обов’язковий. **HPA** поруч із Deployment у **не-base** оверлеях як раніше (див. k8s.mdc).
92
+ * Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) — для HPA, що лишився після
93
+ * збірки, `minReplicas === 1`, `maxReplicas === 1`, PDB `minAvailable === 0`; **прод** — `minReplicas >= 2`,
94
+ * `maxReplicas >= 2`, `minAvailable >= 1`.
93
95
  *
94
- * **Прод-оверрайди в kustomization.yaml:** для прод overlays (не dev-like) `kustomization.yaml` у своїх
95
- * inline `patches[]` повинен змінювати `/spec/minReplicas` і `/spec/maxReplicas` для
96
- * **HorizontalPodAutoscaler**, і `/spec/minAvailable` для **PodDisruptionBudget** — щоб прод-мінімуми
97
- * з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
98
- * наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
99
- * яке буде оцінено під час збірки Kustomize).
96
+ * **Прод-оверрайди в kustomization.yaml:** для прод overlays (не dev-like) у `patches[]` потрібні перевизначення
97
+ * **`/spec/minReplicas`** і **`/spec/maxReplicas`** для **HorizontalPodAutoscaler** лише якщо успадковане base-дерево
98
+ * містить HPA **і** base **не** видаляє його через **`$patch: delete`**. **`/spec/minAvailable`** для **PDB** —
99
+ * якщо в base-дереві є Deployment і PDB. Формат patch — JSON6902 або Strategic Merge; наявність шляхів —
100
+ * `kustomizationPatchPathsByTargetKind`.
100
101
  *
101
102
  * **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
102
103
  * `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`,
@@ -310,6 +311,10 @@ const KIND_FIELD_RE = /^\s*kind:\s*(\S+)\s*$/
310
311
  const TYPE_FIELD_RE = /^\s*type:\s*(\S+)\s*$/
311
312
  const YAML_DOC_SEPARATOR_LINE_RE = /^---\s*$/
312
313
  const HEALTHCHECK_DELETE_RE = /\$patch:\s*delete/u
314
+ /** Strategic-merge patch видалення **HorizontalPodAutoscaler** у kustomization (k8s.mdc). */
315
+ const HPA_STRATEGIC_DELETE_RE = /\$patch:\s*delete/u
316
+ /** Рядок **kind:** для HPA у strategic-merge patch. */
317
+ const HPA_KIND_LINE_RE = /^kind:\s*HorizontalPodAutoscaler\b/m
313
318
  const HEALTHCHECK_KIND_RE = /kind:\s*HealthCheckPolicy/u
314
319
  const METADATA_LINE_RE = /metadata:/u
315
320
  const NAME_NON_EMPTY_RE = /name:\s*\S+/u
@@ -2144,6 +2149,60 @@ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
2144
2149
  return true
2145
2150
  }
2146
2151
 
2152
+ /**
2153
+ * Чи вміст strategic-merge patch оголошує видалення **HorizontalPodAutoscaler** (`$patch: delete`, `kind:`).
2154
+ * @param {string} text вміст поля **patch** або файлу merge
2155
+ * @returns {boolean} true, якщо присутні **`$patch: delete`** і **`kind: HorizontalPodAutoscaler`**
2156
+ */
2157
+ export function patchTextDeclaresHpaStrategicDelete(text) {
2158
+ const t = typeof text === 'string' ? text : ''
2159
+ if (!HPA_STRATEGIC_DELETE_RE.test(t)) return false
2160
+ return HPA_KIND_LINE_RE.test(t)
2161
+ }
2162
+
2163
+ /**
2164
+ * Чи **kustomization.yaml** (inline **patches** / **patchesStrategicMerge**) містить strategic-merge видалення HPA.
2165
+ * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
2166
+ * @param {string} rootNorm нормалізований корінь репо
2167
+ * @returns {Promise<boolean>} true, якщо знайдено patch видалення HPA
2168
+ */
2169
+ async function kustomizationDeclaresHpaStrategicDelete(kustAbs, rootNorm) {
2170
+ const kust = await readFirstYamlObject(kustAbs)
2171
+ if (kust === null) return false
2172
+ const kustDir = dirname(kustAbs)
2173
+ const patches = kust.patches
2174
+ if (Array.isArray(patches)) {
2175
+ for (const p of patches) {
2176
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
2177
+ const rec = /** @type {Record<string, unknown>} */ (p)
2178
+ const inline = rec.patch
2179
+ if (typeof inline === 'string' && patchTextDeclaresHpaStrategicDelete(inline)) return true
2180
+ const pathRef = rec.path
2181
+ if (typeof pathRef === 'string' && pathRef.trim() !== '') {
2182
+ const abs = resolve(kustDir, pathRef.trim())
2183
+ if (resolvedFilePathIsUnderRoot(rootNorm, abs)) {
2184
+ const body = await tryReadFileUtf8(abs)
2185
+ if (body !== undefined && patchTextDeclaresHpaStrategicDelete(body)) return true
2186
+ }
2187
+ }
2188
+ }
2189
+ }
2190
+ }
2191
+ const sm = kust.patchesStrategicMerge
2192
+ if (Array.isArray(sm)) {
2193
+ for (const ref of sm) {
2194
+ if (typeof ref === 'string' && ref.trim() !== '') {
2195
+ const abs = resolve(kustDir, ref.trim())
2196
+ if (resolvedFilePathIsUnderRoot(rootNorm, abs)) {
2197
+ const body = await tryReadFileUtf8(abs)
2198
+ if (body !== undefined && patchTextDeclaresHpaStrategicDelete(body)) return true
2199
+ }
2200
+ }
2201
+ }
2202
+ }
2203
+ return false
2204
+ }
2205
+
2147
2206
  /**
2148
2207
  * Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
2149
2208
  * @param {string} rootAbs абсолютний корінь
@@ -2508,10 +2567,25 @@ function scanForbiddenManifestsInYamlDocuments(rel, body, fail) {
2508
2567
  }
2509
2568
 
2510
2569
  /**
2511
- * Рекомендоване значення **`resources.requests.cpu`** за замовчуванням для підказки в повідомленнях (k8s.mdc).
2570
+ * Рекомендоване **`resources.requests.cpu`** поза шарем base для підказок у повідомленнях (k8s.mdc).
2512
2571
  */
2513
2572
  export const DEFAULT_CONTAINER_CPU_REQUEST = '0.5'
2514
2573
 
2574
+ /**
2575
+ * Рекомендоване **`resources.requests.memory`** поза шарем base для підказок у повідомленнях (k8s.mdc).
2576
+ */
2577
+ export const DEFAULT_CONTAINER_MEMORY_REQUEST = '512Mi'
2578
+
2579
+ /**
2580
+ * Обов’язковий **`resources.requests.cpu`** у **`…/k8s/…/base/…`** (k8s.mdc).
2581
+ */
2582
+ export const K8S_BASE_CONTAINER_CPU_REQUEST = '0.02'
2583
+
2584
+ /**
2585
+ * Обов’язковий **`resources.requests.memory`** у **`…/k8s/…/base/…`** (k8s.mdc).
2586
+ */
2587
+ export const K8S_BASE_CONTAINER_MEMORY_REQUEST = '128Mi'
2588
+
2515
2589
  /**
2516
2590
  * Чи значення `resources.requests.cpu` записане у валідному вигляді:
2517
2591
  * непорожній рядок (`"500m"`, `"0.5"`) або додатне число.
@@ -2525,20 +2599,59 @@ function isValidCpuRequestValue(cpu) {
2525
2599
  }
2526
2600
 
2527
2601
  /**
2528
- * Перевірка поля **`resources`** для одного контейнера **Deployment**: вимагає не лише присутність
2529
- * **`resources`**, а й непорожнє **`resources.requests.cpu`** (див. k8s.mdc). Якщо конкретне
2530
- * значення ще не обрано як безпечне за замовчуванням рекомендовано **`DEFAULT_CONTAINER_CPU_REQUEST`**.
2602
+ * Чи значення `resources.requests.memory` записане у валідному вигляді (непорожній рядок або додатне число).
2603
+ * @param {unknown} mem значення поля `resources.requests.memory`
2604
+ * @returns {boolean} true, якщо значення прийнятне
2605
+ */
2606
+ function isValidMemoryRequestValue(mem) {
2607
+ if (typeof mem === 'string') return mem.trim() !== ''
2608
+ if (typeof mem === 'number') return Number.isFinite(mem) && mem > 0
2609
+ return false
2610
+ }
2611
+
2612
+ /**
2613
+ * Чи CPU у base-шарі збігається з каноном **`0.02`** (рядок або число).
2614
+ * @param {unknown} cpu значення **requests.cpu**
2615
+ * @returns {boolean} true, якщо дорівнює канону base
2616
+ */
2617
+ function isBaseCanonCpuValue(cpu) {
2618
+ if (typeof cpu === 'number' && Number.isFinite(cpu)) {
2619
+ return cpu === 0.02
2620
+ }
2621
+ if (typeof cpu === 'string' && cpu.trim() !== '') {
2622
+ const t = cpu.trim()
2623
+ if (t === K8S_BASE_CONTAINER_CPU_REQUEST) return true
2624
+ const n = Number(t)
2625
+ return Number.isFinite(n) && n === 0.02
2626
+ }
2627
+ return false
2628
+ }
2629
+
2630
+ /**
2631
+ * Чи memory у base-шарі збігається з каноном **`128Mi`** (рядок Quantity; **`Mi`** без урахування регістру).
2632
+ * @param {unknown} mem значення **requests.memory**
2633
+ * @returns {boolean} true, якщо дорівнює канону base
2634
+ */
2635
+ function isBaseCanonMemoryValue(mem) {
2636
+ if (typeof mem !== 'string' || mem.trim() === '') return false
2637
+ return /^128Mi$/iu.test(mem.trim())
2638
+ }
2639
+
2640
+ /**
2641
+ * Перевірка поля **`resources`** для одного контейнера **Deployment** (k8s.mdc): **requests.cpu** і **requests.memory**;
2642
+ * у шарі **`…/k8s/…/base/…`** — жорстко **`0.02`** / **`128Mi`**.
2531
2643
  * @param {unknown} c елемент **containers[]**
2532
2644
  * @param {string} label підпис у повідомленні
2645
+ * @param {boolean} inK8sBaseLayer файл маніфесту під **`…/k8s/…/base/…`**
2533
2646
  * @returns {string | null} текст порушення або null
2534
2647
  */
2535
- function deploymentContainerResourcesViolation(c, label) {
2648
+ function deploymentContainerResourcesViolation(c, label, inK8sBaseLayer) {
2536
2649
  if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
2537
2650
  return null
2538
2651
  }
2539
2652
  const cont = /** @type {Record<string, unknown>} */ (c)
2540
2653
  if (!('resources' in cont)) {
2541
- return `контейнер "${label}": відсутнє поле resources — додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
2654
+ return `контейнер "${label}": відсутнє поле resources — додай resources.requests.cpu та resources.requests.memory (поза base за замовчуванням cpu=${DEFAULT_CONTAINER_CPU_REQUEST}, memory=${DEFAULT_CONTAINER_MEMORY_REQUEST}; у base — cpu='${K8S_BASE_CONTAINER_CPU_REQUEST}', memory='${K8S_BASE_CONTAINER_MEMORY_REQUEST}') (див. k8s.mdc)`
2542
2655
  }
2543
2656
  const r = cont.resources
2544
2657
  if (r === null || typeof r !== 'object' || Array.isArray(r)) {
@@ -2547,24 +2660,39 @@ function deploymentContainerResourcesViolation(c, label) {
2547
2660
  const resources = /** @type {Record<string, unknown>} */ (r)
2548
2661
  const requests = resources.requests
2549
2662
  if (requests === null || requests === undefined || typeof requests !== 'object' || Array.isArray(requests)) {
2550
- return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
2663
+ return `контейнер "${label}": додай resources.requests.cpu та resources.requests.memory (поза base за замовчуванням cpu=${DEFAULT_CONTAINER_CPU_REQUEST}, memory=${DEFAULT_CONTAINER_MEMORY_REQUEST}) (див. k8s.mdc)`
2551
2664
  }
2552
2665
  const req = /** @type {Record<string, unknown>} */ (requests)
2553
2666
  if (!('cpu' in req)) {
2554
- return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
2667
+ return `контейнер "${label}": додай resources.requests.cpu (поза base за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
2555
2668
  }
2556
2669
  if (!isValidCpuRequestValue(req.cpu)) {
2557
2670
  return `контейнер "${label}": resources.requests.cpu має бути непорожнім значенням (наприклад "500m" або ${DEFAULT_CONTAINER_CPU_REQUEST}) (зараз: ${JSON.stringify(req.cpu)}) (див. k8s.mdc)`
2558
2671
  }
2672
+ if (!('memory' in req)) {
2673
+ return `контейнер "${label}": додай resources.requests.memory (поза base за замовчуванням ${DEFAULT_CONTAINER_MEMORY_REQUEST}) (див. k8s.mdc)`
2674
+ }
2675
+ if (!isValidMemoryRequestValue(req.memory)) {
2676
+ return `контейнер "${label}": resources.requests.memory має бути непорожнім значенням (наприклад "${DEFAULT_CONTAINER_MEMORY_REQUEST}") (зараз: ${JSON.stringify(req.memory)}) (див. k8s.mdc)`
2677
+ }
2678
+ if (inK8sBaseLayer) {
2679
+ if (!isBaseCanonCpuValue(req.cpu)) {
2680
+ return `контейнер "${label}": у шарі k8s/.../base resources.requests.cpu має бути рівно '${K8S_BASE_CONTAINER_CPU_REQUEST}' (допускається число 0.02) — зараз ${JSON.stringify(req.cpu)} (див. k8s.mdc)`
2681
+ }
2682
+ if (!isBaseCanonMemoryValue(req.memory)) {
2683
+ return `контейнер "${label}": у шарі k8s/.../base resources.requests.memory має бути рівно '${K8S_BASE_CONTAINER_MEMORY_REQUEST}' (суфікс Mi без урахування регістру) — зараз ${JSON.stringify(req.memory)} (див. k8s.mdc)`
2684
+ }
2685
+ }
2559
2686
  return null
2560
2687
  }
2561
2688
 
2562
2689
  /**
2563
2690
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
2564
2691
  * @param {unknown} manifest корінь YAML-документа як запис JavaScript
2692
+ * @param {boolean} [inK8sBaseLayer] true, якщо файл лежить під **`…/k8s/…/base/…`**
2565
2693
  * @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
2566
2694
  */
2567
- export function deploymentResourcesViolation(manifest) {
2695
+ export function deploymentResourcesViolation(manifest, inK8sBaseLayer = false) {
2568
2696
  if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
2569
2697
  return null
2570
2698
  const rec = /** @type {Record<string, unknown>} */ (manifest)
@@ -2584,7 +2712,7 @@ export function deploymentResourcesViolation(manifest) {
2584
2712
  typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
2585
2713
  ? c.name
2586
2714
  : `#${i + 1}`
2587
- const v = deploymentContainerResourcesViolation(c, label)
2715
+ const v = deploymentContainerResourcesViolation(c, label, inK8sBaseLayer)
2588
2716
  if (v !== null) {
2589
2717
  return v
2590
2718
  }
@@ -3845,7 +3973,9 @@ function failIfK8sPolicyNamespaceRulesViolated(rel, docIndex, obj, skipMetaNs, i
3845
3973
  * @returns {void}
3846
3974
  */
3847
3975
  function failIfK8sPolicyResourceRulesViolated(rel, baseLower, docIndex, obj, fail) {
3848
- const resV = deploymentResourcesViolation(obj)
3976
+ const relPosix = rel.replaceAll('\\', '/')
3977
+ const inK8sBaseLayer = isK8sYamlUnderBaseDirectory(relPosix)
3978
+ const resV = deploymentResourcesViolation(obj, inK8sBaseLayer)
3849
3979
  if (resV !== null) {
3850
3980
  fail(`${rel}: Deployment (документ ${docIndex}): ${resV}`)
3851
3981
  }
@@ -4977,6 +5107,38 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
4977
5107
  }
4978
5108
  }
4979
5109
 
5110
+ /**
5111
+ * У **`…/k8s/…/base/kustomization.yaml`**: якщо з **resources** тягнеться HPA разом із Deployment,
5112
+ * обов’язковий strategic-merge patch з **`$patch: delete`** для **HorizontalPodAutoscaler** (k8s.mdc).
5113
+ * @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
5114
+ * @param {string} rel відносний шлях для повідомлень
5115
+ * @param {string} rootNorm корінь репо
5116
+ * @param {(msg: string) => void} fail callback
5117
+ * @param {(msg: string) => void} passFn success
5118
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
5119
+ * @returns {Promise<void>}
5120
+ */
5121
+ async function verifyK8sBaseKustomizeHpaDeletedWhenInherited(
5122
+ kustAbs,
5123
+ rel,
5124
+ rootNorm,
5125
+ fail,
5126
+ passFn,
5127
+ getTreeFlags
5128
+ ) {
5129
+ const { hasDeployment, hasHpa } = await getTreeFlags(kustAbs)
5130
+ if (!hasDeployment || !hasHpa) {
5131
+ return
5132
+ }
5133
+ if (await kustomizationDeclaresHpaStrategicDelete(kustAbs, rootNorm)) {
5134
+ passFn(`${rel}: base kustomization містить $patch: delete для HorizontalPodAutoscaler (k8s.mdc)`)
5135
+ } else {
5136
+ fail(
5137
+ `${rel}: у дереві base є HorizontalPodAutoscaler — додай у цей kustomization.yaml strategic-merge patch з $patch: delete та kind: HorizontalPodAutoscaler (k8s.mdc)`
5138
+ )
5139
+ }
5140
+ }
5141
+
4980
5142
  /**
4981
5143
  * `kustomization` overlay, що посилається на `…/k8s/…/base`, не може додавати HPA / PDB як окремі YAML,
4982
5144
  * поки в наслідуваному base немає Deployment.
@@ -5084,6 +5246,7 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
5084
5246
  if (kust !== null) {
5085
5247
  if (isK8sBaseKustomizationRelPath(rel)) {
5086
5248
  await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
5249
+ await verifyK8sBaseKustomizeHpaDeletedWhenInherited(kustAbs, rel, rootNorm, fail, passFn, getTreeFlags)
5087
5250
  } else {
5088
5251
  await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(rootNorm, kustAbs, rel, kust, fail, passFn, getTreeFlags)
5089
5252
  }
@@ -5092,80 +5255,108 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
5092
5255
  }
5093
5256
 
5094
5257
  /**
5095
- * Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
5258
+ * @typedef {{ needsHpaReplicaPatches: boolean, needsPdbMinAvailablePatch: boolean }} ProdOverlayHpaPdbOverrideNeeds
5259
+ */
5260
+
5261
+ /**
5262
+ * Перевіряє наявність прод-оверрайдів у **kustomization.yaml** залежно від того, що успадковується з base.
5096
5263
  * @param {Record<string, unknown>} kust об'єкт kustomization
5097
5264
  * @param {string} rel відносний шлях для повідомлень
5098
5265
  * @param {(msg: string) => void} fail callback при помилці
5099
5266
  * @param {(msg: string) => void} passFn callback при успіху
5267
+ * @param {ProdOverlayHpaPdbOverrideNeeds} needs що саме має бути в **patches[]**
5100
5268
  */
5101
- function checkProdOverridesInKustomization(kust, rel, fail, passFn) {
5269
+ function checkProdOverridesInKustomization(kust, rel, fail, passFn, needs) {
5102
5270
  const byKind = kustomizationPatchPathsByTargetKind(kust)
5103
5271
  const hpaPaths = byKind.get('HorizontalPodAutoscaler') ?? new Set()
5104
5272
  const pdbPaths = byKind.get('PodDisruptionBudget') ?? new Set()
5105
5273
  let ok = true
5106
- if (!hpaPaths.has('/spec/minReplicas')) {
5107
- fail(
5108
- `${rel}: прод-оверлей має перевизначати spec.minReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
5109
- )
5110
- ok = false
5111
- }
5112
- if (!hpaPaths.has('/spec/maxReplicas')) {
5113
- fail(
5114
- `${rel}: прод-оверлей має перевизначати spec.maxReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
5115
- )
5116
- ok = false
5274
+ if (needs.needsHpaReplicaPatches) {
5275
+ if (!hpaPaths.has('/spec/minReplicas')) {
5276
+ fail(
5277
+ `${rel}: прод-оверлей має перевизначати spec.minReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
5278
+ )
5279
+ ok = false
5280
+ }
5281
+ if (!hpaPaths.has('/spec/maxReplicas')) {
5282
+ fail(
5283
+ `${rel}: прод-оверлей має перевизначати spec.maxReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
5284
+ )
5285
+ ok = false
5286
+ }
5117
5287
  }
5118
- if (!pdbPaths.has('/spec/minAvailable')) {
5119
- fail(
5120
- `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5121
- )
5122
- ok = false
5288
+ if (needs.needsPdbMinAvailablePatch) {
5289
+ if (!pdbPaths.has('/spec/minAvailable')) {
5290
+ fail(
5291
+ `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
5292
+ )
5293
+ ok = false
5294
+ }
5123
5295
  }
5124
5296
  if (ok) {
5125
- passFn(`${rel}: прод-оверрайди HPA minReplicas/maxReplicas і PDB minAvailable присутні (k8s.mdc)`)
5297
+ passFn(`${rel}: прод-оверрайди HPA/PDB за потреби присутні (k8s.mdc)`)
5126
5298
  }
5127
5299
  }
5128
5300
 
5129
5301
  /**
5130
- * Чи прод-оверлей **реально потребує** overrides для HPA/PDB.
5302
+ * Які прод-оверрайди потрібні для **kustomization.yaml** (не dev-like), що посилається на **…/k8s/…/base**.
5131
5303
  *
5132
- * Overrides потрібні лише якщо оверлей (non-dev-like) посилається на `…/k8s/…/base` і у **base**-дереві
5133
- * одночасно є:
5134
- * - `Deployment` (у шарі `…/k8s/…/base/`), і
5135
- * - `HorizontalPodAutoscaler` і/або `PodDisruptionBudget`.
5304
+ * **HPA:** patches на **`minReplicas`/`maxReplicas`** лише якщо в base-дереві є HPA **і** base **не** містить
5305
+ * strategic-merge **`$patch: delete`** для **HorizontalPodAutoscaler** (k8s.mdc).
5136
5306
  *
5137
- * Тоді base зазвичай тримає dev-like значення (`1`/`1`/`0`), і прод-оверлей має їх підняти (див. k8s.mdc).
5307
+ * **PDB:** **`minAvailable`** якщо в base-дереві є Deployment і PDB.
5138
5308
  * @param {string} rootNorm нормалізований корінь репозиторію
5139
5309
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5140
- * @returns {Promise<boolean>} true якщо потрібні overrides, інакше false
5310
+ * @returns {Promise<ProdOverlayHpaPdbOverrideNeeds>} прапорці потрібних перевизначень
5141
5311
  */
5142
- export async function prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs) {
5312
+ export async function prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs) {
5143
5313
  const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
5144
5314
  const segment = k8sEnvSegmentFromRelPath(rel)
5145
- if (segment === null || isDevLikeK8sEnvSegment(segment)) return false
5315
+ if (segment === null || isDevLikeK8sEnvSegment(segment)) {
5316
+ return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5317
+ }
5146
5318
 
5147
5319
  const kust = await readFirstYamlObject(kustAbs)
5148
- if (kust === null) return false
5320
+ if (kust === null) return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5149
5321
 
5150
5322
  const kustDir = dirname(kustAbs)
5151
5323
  const pathRefs = resourcePathRefsFromKustomizationObject(kust)
5152
5324
  const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm)
5153
- if (baseDirs.length === 0) return false
5154
-
5155
- const flags = await Promise.all(
5156
- baseDirs.map(bd => kustomizeResourceTreeHpaPdbDeploymentFlags(join(bd, 'kustomization.yaml'), rootNorm))
5157
- )
5325
+ if (baseDirs.length === 0) return { needsHpaReplicaPatches: false, needsPdbMinAvailablePatch: false }
5326
+
5327
+ let needsHpaReplicaPatches = false
5328
+ let needsPdbMinAvailablePatch = false
5329
+ for (const bd of baseDirs) {
5330
+ const bk = join(bd, 'kustomization.yaml')
5331
+ const f = await kustomizeResourceTreeHpaPdbDeploymentFlags(bk, rootNorm)
5332
+ const baseDeletesHpa = await kustomizationDeclaresHpaStrategicDelete(bk, rootNorm)
5333
+ if (f.hasDeployment && f.hasPdb) {
5334
+ needsPdbMinAvailablePatch = true
5335
+ }
5336
+ if (f.hasDeployment && f.hasHpa && !baseDeletesHpa) {
5337
+ needsHpaReplicaPatches = true
5338
+ }
5339
+ }
5340
+ return { needsHpaReplicaPatches, needsPdbMinAvailablePatch }
5341
+ }
5158
5342
 
5159
- return flags.some(f => f.hasDeployment && (f.hasHpa || f.hasPdb))
5343
+ /**
5344
+ * Чи прод-оверлей потребує **будь-яких** overrides HPA/PDB у **patches[]** (зведений прапорець).
5345
+ * @param {string} rootNorm нормалізований корінь репозиторію
5346
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5347
+ * @returns {Promise<boolean>} true, якщо потрібен хоча б один тип оверрайду
5348
+ */
5349
+ export async function prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs) {
5350
+ const n = await prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs)
5351
+ return n.needsHpaReplicaPatches || n.needsPdbMinAvailablePatch
5160
5352
  }
5161
5353
 
5162
5354
  /**
5163
- * Для прод kustomization.yaml вимагає patches, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
5164
- * на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**.
5355
+ * Для прод kustomization.yaml вимагає **patches[]** за потреби: **`/spec/minReplicas`** і **`/spec/maxReplicas`**
5356
+ * для **HorizontalPodAutoscaler** (якщо в успадкованому base лишився HPA без delete-patch), **`/spec/minAvailable`**
5357
+ * для **PDB** (якщо в base є PDB).
5165
5358
  *
5166
5359
  * Не застосовується до dev-like (base / dev / *-qa).
5167
- *
5168
- * Також **не застосовується**, якщо оверлей не наслідує base з Deployment + HPA/PDB (див. `prodOverlayNeedsHpaPdbOverrides`).
5169
5360
  * @param {string} root корінь репозиторію
5170
5361
  * @param {string[]} yamlFilesAbs yaml під k8s
5171
5362
  * @param {(msg: string) => void} fail callback при помилці
@@ -5176,9 +5367,10 @@ async function validateProdKustomizationOverrides(root, yamlFilesAbs, fail, pass
5176
5367
  const kustFiles = yamlFilesAbs.filter(abs => basename(abs) === 'kustomization.yaml')
5177
5368
  for (const kustAbs of kustFiles) {
5178
5369
  const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
5179
- if (!(await prodOverlayNeedsHpaPdbOverrides(rootNorm, kustAbs))) continue
5370
+ const needs = await prodOverlayHpaPdbOverrideNeeds(rootNorm, kustAbs)
5371
+ if (!needs.needsHpaReplicaPatches && !needs.needsPdbMinAvailablePatch) continue
5180
5372
  const kust = await readFirstYamlObject(kustAbs)
5181
- if (kust !== null) checkProdOverridesInKustomization(kust, rel, fail, passFn)
5373
+ if (kust !== null) checkProdOverridesInKustomization(kust, rel, fail, passFn, needs)
5182
5374
  }
5183
5375
  }
5184
5376
 
@@ -5272,12 +5464,22 @@ function validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, pdbR
5272
5464
  * @param {Record<string, unknown>} deployment об'єкт Deployment
5273
5465
  * @param {string} deployRel відносний шлях каталогу для повідомлень
5274
5466
  * @param {boolean} isDevLike чи середовище dev-like
5467
+ * @param {boolean} isK8sBaseLayer чи каталог під **`…/k8s/…/base/`** (HPA поруч не вимагаємо — прибирається в kustomization)
5275
5468
  * @param {Record<string, unknown>[]} hpaDocs HPA-документи каталогу
5276
5469
  * @param {Record<string, unknown>[]} pdbDocs PDB-документи каталогу
5277
5470
  * @param {(msg: string) => void} fail callback при помилці
5278
5471
  * @param {(msg: string) => void} passFn callback при успіху
5279
5472
  */
5280
- function validateSingleDeploymentHpaPdbTopology(deployment, deployRel, isDevLike, hpaDocs, pdbDocs, fail, passFn) {
5473
+ function validateSingleDeploymentHpaPdbTopology(
5474
+ deployment,
5475
+ deployRel,
5476
+ isDevLike,
5477
+ isK8sBaseLayer,
5478
+ hpaDocs,
5479
+ pdbDocs,
5480
+ fail,
5481
+ passFn
5482
+ ) {
5281
5483
  const deployName = manifestMetadataName(deployment)
5282
5484
  const appLabel = deploymentAppLabel(deployment)
5283
5485
  if (deployName === null) {
@@ -5294,7 +5496,9 @@ function validateSingleDeploymentHpaPdbTopology(deployment, deployRel, isDevLike
5294
5496
  } else {
5295
5497
  fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
5296
5498
  }
5297
- validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5499
+ if (!isK8sBaseLayer) {
5500
+ validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
5501
+ }
5298
5502
  validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, `${deployRel}/${PDB_FILENAME}`, fail, passFn)
5299
5503
  }
5300
5504
 
@@ -5310,11 +5514,30 @@ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
5310
5514
  const relDir = relative(root, dir).replaceAll('\\', '/')
5311
5515
  const segment = k8sEnvSegmentFromRelPath(relDir + '/')
5312
5516
  const isDevLike = isDevLikeK8sEnvSegment(segment)
5517
+ const isK8sBaseLayer = isK8sYamlUnderBaseDirectory(`${relDir}/probe.yaml`)
5518
+ if (isK8sBaseLayer && deployments.length > 0) {
5519
+ const hpaAbs = join(dir, HPA_FILENAME)
5520
+ if (existsSync(hpaAbs)) {
5521
+ const prefix = relDir === '' ? '.' : relDir
5522
+ fail(
5523
+ `${prefix}/${HPA_FILENAME}: у шарі k8s/.../base не тримай локальний hpa.yaml — прибери HorizontalPodAutoscaler через $patch: delete у base/kustomization.yaml (k8s.mdc)`
5524
+ )
5525
+ }
5526
+ }
5313
5527
  const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
5314
5528
  const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
5315
5529
  const deployRel = relDir === '' ? '.' : relDir
5316
5530
  for (const deployment of deployments) {
5317
- validateSingleDeploymentHpaPdbTopology(deployment, deployRel, isDevLike, hpaDocs, pdbDocs, fail, passFn)
5531
+ validateSingleDeploymentHpaPdbTopology(
5532
+ deployment,
5533
+ deployRel,
5534
+ isDevLike,
5535
+ isK8sBaseLayer,
5536
+ hpaDocs,
5537
+ pdbDocs,
5538
+ fail,
5539
+ passFn
5540
+ )
5318
5541
  }
5319
5542
  }
5320
5543
 
@@ -5333,9 +5556,9 @@ async function extractDeploymentsFromFile(filePath) {
5333
5556
 
5334
5557
  /**
5335
5558
  * Для кожного **Deployment** у шарі **`…/k8s/…/base/`** (будь-який YAML у відповідному каталозі) перевіряє:
5336
- * у тому ж каталозі повинні бути `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`),
5337
- * а сам Deployment канонічні **topologySpreadConstraints**. Env-залежні межі (`minReplicas`,
5338
- * `minAvailable`) — за сегментом після `/k8s/`: `base` / `dev` / `*-qa` = dev-like, решта прод.
5559
+ * заборона локального **`hpa.yaml`**; **`pdb.yaml`** (валідний `policy/v1`); канонічні **topologySpreadConstraints**.
5560
+ * HPA для dev прибирається в **`base/kustomization.yaml`** через **`$patch: delete`** (див. `verifyK8sBaseKustomizeHpaDeletedWhenInherited`).
5561
+ * Env-залежні межі — за сегментом після `/k8s/`: dev-like vs прод.
5339
5562
  * @param {string} root корінь репозиторію
5340
5563
  * @param {string[]} yamlFilesAbs yaml під k8s
5341
5564
  * @param {(msg: string) => void} fail callback при помилці
@@ -6018,7 +6241,7 @@ function applyConversionsToDoc(doc, conversions) {
6018
6241
  byPatch.set(c.index, slot)
6019
6242
  }
6020
6243
 
6021
- const sortedIdx = byPatch.keys().toSorted((a, b) => b - a)
6244
+ const sortedIdx = [...byPatch.keys()].toSorted((a, b) => b - a)
6022
6245
  for (const i of sortedIdx) {
6023
6246
  const slot = byPatch.get(i)
6024
6247
  if (slot === undefined) continue