@nitra/cursor 1.8.212 → 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.
@@ -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
@@ -479,7 +484,7 @@ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFile
479
484
  }
480
485
 
481
486
  /**
482
- * Лексичне порівняння двох тuplіе рядків через `localeCompare('en', { sensitivity: 'base' })`.
487
+ * Лексичне порівняння двох tuple рядків через `localeCompare('en', { sensitivity: 'base' })`.
483
488
  * Менший за довжиною список доповнюється порожніми рядками.
484
489
  * @param {string[]} a перший tuple
485
490
  * @param {string[]} b другий tuple
@@ -594,7 +599,7 @@ function jsonPointerPathsAreDisjoint(paths) {
594
599
  }
595
600
 
596
601
  /**
597
- * Парсить рядок JSON6902-патчa в плоский масив операцій `{ op, path }` (без значень).
602
+ * Парсить рядок JSON6902-патча в плоский масив операцій `{ op, path }` (без значень).
598
603
  * Повертає `null`, якщо це не YAML-масив об'єктів з полями `op`/`path` як рядки.
599
604
  * @param {string} raw тіло inline `patch:` (literal block scalar)
600
605
  * @returns {{ op: string, path: string }[] | null} нормалізований список ops або `null` за невідповідного формату
@@ -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
@@ -183,6 +183,12 @@ const TARGETS = [
183
183
  rule: 'js-bun-db',
184
184
  walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
185
185
  },
186
+ {
187
+ namespace: 'js_bun_redis.package_json',
188
+ policyDir: 'js_bun_redis',
189
+ rule: 'js-bun-redis',
190
+ walk: { match: rel => rel.endsWith('/package.json') || rel === 'package.json' }
191
+ },
186
192
  {
187
193
  namespace: 'js_run.package_json',
188
194
  policyDir: 'js_run',
@@ -257,7 +263,10 @@ function collectFiles(root, match) {
257
263
  continue
258
264
  }
259
265
  if (!e.isFile()) continue
260
- const rel = abs.slice(root.length + 1).split(sep).join('/')
266
+ const rel = abs
267
+ .slice(root.length + 1)
268
+ .split(sep)
269
+ .join('/')
261
270
  if (match(rel)) out.push(rel)
262
271
  }
263
272
  }
@@ -297,11 +306,10 @@ function runConftestForTarget(conftestBin, target, files) {
297
306
  return 0
298
307
  }
299
308
  console.log(`\n▶ conftest (${target.namespace} — ${files.length} файл(ів))`)
300
- const r = spawnSync(
301
- conftestBin,
302
- ['test', ...files, '-p', policyAbs, '--namespace', target.namespace, '--no-color'],
303
- { stdio: 'inherit', env: process.env }
304
- )
309
+ const r = spawnSync(conftestBin, ['test', ...files, '-p', policyAbs, '--namespace', target.namespace, '--no-color'], {
310
+ stdio: 'inherit',
311
+ env: process.env
312
+ })
305
313
  if (r.error) {
306
314
  console.error(`❌ Не вдалося запустити conftest: ${r.error.message}`)
307
315
  return 1
@@ -425,8 +425,7 @@ export function findPgFormatLikeQueryWrapperInText(content, virtualPath = 'scan.
425
425
  for (const prop of properties) {
426
426
  if (!prop || prop.type !== 'Property') continue
427
427
  const key = prop.key
428
- const keyName =
429
- key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
428
+ const keyName = key && key.type === 'Identifier' ? key.name : key && key.type === 'Literal' ? key.value : null
430
429
  if (keyName !== 'query') continue
431
430
  const value = prop.value
432
431
  if (!value || (value.type !== 'FunctionExpression' && value.type !== 'ArrowFunctionExpression')) continue