@nitra/cursor 1.8.113 → 1.8.116

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
 
@@ -214,6 +218,15 @@ spec:
214
218
 
215
219
  Якщо в `k8s/base/` є **`configmap.yaml`** і **Deployment**, і цей Deployment посилається рівно на **один** ConfigMap — `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment. Точні умови перевірки — **`check-k8s.mjs`**.
216
220
 
221
+ ## ConfigMap для Hasura-Deployment
222
+
223
+ Якщо в `k8s/base/` поруч із **`configmap.yaml`** є **Deployment** з образом **`hasura/graphql-engine`**, у `data` ConfigMap **обов'язково** має бути ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`**. Точні умови перевірки — **`check-k8s.mjs`**.
224
+
225
+ ```yaml
226
+ data:
227
+ HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: 'true'
228
+ ```
229
+
217
230
  ## Kustomize: структура каталогів (`base` / overlays)
218
231
 
219
232
  Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
@@ -273,6 +286,122 @@ spec:
273
286
 
274
287
  **`check k8s`:** заборонено **`kind: Ingress`**.
275
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`.
302
+ - PDB: `minAvailable === 0`.
303
+
304
+ **Прод-середовища** — усе інше (будь-який overlay без суфікса `-qa`):
305
+
306
+ - HPA: `minReplicas >= 2`, `maxReplicas >= 2`.
307
+ - PDB: `minAvailable >= 1`.
308
+
309
+ ### Приклади
310
+
311
+ ```yaml title="k8s/base/hpa.yaml"
312
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
313
+ apiVersion: autoscaling/v2
314
+ kind: HorizontalPodAutoscaler
315
+ metadata:
316
+ name: backend-api
317
+ spec:
318
+ scaleTargetRef:
319
+ apiVersion: apps/v1
320
+ kind: Deployment
321
+ name: backend-api
322
+ minReplicas: 1 # прод: >= 2
323
+ maxReplicas: 10
324
+ metrics:
325
+ - type: Resource
326
+ resource:
327
+ name: cpu
328
+ target:
329
+ type: Utilization
330
+ averageUtilization: 70
331
+ behavior:
332
+ scaleUp:
333
+ stabilizationWindowSeconds: 15
334
+ policies:
335
+ - type: Percent
336
+ value: 100
337
+ periodSeconds: 30
338
+ - type: Pods
339
+ value: 4
340
+ periodSeconds: 30
341
+ selectPolicy: Max
342
+ scaleDown:
343
+ stabilizationWindowSeconds: 300
344
+ policies:
345
+ - type: Percent
346
+ value: 25
347
+ periodSeconds: 120
348
+ selectPolicy: Min
349
+ ```
350
+
351
+ ```yaml title="k8s/base/pdb.yaml"
352
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/poddisruptionbudget-policy-v1.json
353
+ apiVersion: policy/v1
354
+ kind: PodDisruptionBudget
355
+ metadata:
356
+ name: backend-api
357
+ spec:
358
+ minAvailable: 0 # прод: >= 1
359
+ selector:
360
+ matchLabels:
361
+ app: backend-api
362
+ ```
363
+
364
+ ```yaml title="k8s/base/deploy.yaml (фрагмент)"
365
+ spec:
366
+ template:
367
+ spec:
368
+ topologySpreadConstraints:
369
+ - maxSkew: 1
370
+ topologyKey: kubernetes.io/hostname
371
+ whenUnsatisfiable: ScheduleAnyway
372
+ labelSelector:
373
+ matchLabels:
374
+ app: backend-api
375
+ ```
376
+
377
+ В overlays для проду `minReplicas`, `maxReplicas`, `minAvailable` підіймаєш до прод-мінімумів через **Kustomize patches** або окремі `hpa.yaml` / `pdb.yaml` у каталозі overlay (тоді перевірка спрацьовує за їхнім середовищем).
378
+
379
+ ## HorizontalPodAutoscaler: `autoscaling/v2`
380
+
381
+ У маніфестах під **`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`**.
382
+
383
+ ```yaml
384
+ # yaml-language-server: $schema=https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/v1.33.9-standalone-strict/horizontalpodautoscaler-autoscaling-v2.json
385
+ apiVersion: autoscaling/v2
386
+ kind: HorizontalPodAutoscaler
387
+ metadata:
388
+ name: app
389
+ spec:
390
+ scaleTargetRef:
391
+ apiVersion: apps/v1
392
+ kind: Deployment
393
+ name: app
394
+ minReplicas: 1
395
+ maxReplicas: 5
396
+ metrics:
397
+ - type: Resource
398
+ resource:
399
+ name: cpu
400
+ target:
401
+ type: Utilization
402
+ averageUtilization: 70
403
+ ```
404
+
276
405
  3. **JSON patch у kustomization:** де можливо, змінюй ресурс через **`op: replace`** (одна операція на `path`), а не пару **`remove` + `add`** на той самий шлях. **`add`** / **`remove`** лишай лише коли **`replace`** не підходить (наприклад додати новий ключ або прибрати поле без заміни).
277
406
 
278
407
  ```yaml title="overlay/kustomization.yaml (фрагмент)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.113",
3
+ "version": "1.8.116",
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** змішано з іншими ресурсами в одному файлі — перевірка завершується помилкою (розділи маніфести).
@@ -56,6 +58,20 @@
56
58
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
57
59
  *
58
60
  * **Структура `HTTPRoute` для Hasura-Deployment:** звіряється канон 4 правил у **`spec.rules`** (редиректи **`<prefix>/ql`** і **`<prefix>/ql/`** на **`<prefix>/ql/console`** 302, **`PathPrefix <prefix>/ql`** + **URLRewrite** на **`/`**, окреме WebSocket-правило з **`RequestHeaderModifier`** remove **`Authorization`**). **Префікс параметризовано** (рядок перед **`/ql`** у першому Hasura-правилі). **Прив'язка** — за **`metadata.name`** у тому ж каталозі, що й **Deployment** з образом **`hasura/graphql-engine`** (див. k8s.mdc). **Додаткові правила** поверх канону дозволені.
61
+ *
62
+ * **ConfigMap для Hasura-Deployment:** якщо в `k8s/base/` є `configmap.yaml` і поруч Deployment з образом
63
+ * **`hasura/graphql-engine`**, то в `data` ConfigMap обов'язково має бути ключ
64
+ * **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (приймається булеве `true`
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
+ * `minAvailable === 0`; **прод** (решта) — `minReplicas >= 2`, `maxReplicas >= 2`, `minAvailable >= 1`.
72
+ * Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
73
+ * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
74
+ * `labelSelector.matchLabels.app` рівне `spec.selector.matchLabels.app` Deployment.
59
75
  */
60
76
  import { existsSync } from 'node:fs'
61
77
  import { readFile, readdir, stat, unlink } from 'node:fs/promises'
@@ -1755,13 +1771,44 @@ function failIfIngressInDocument(rel, docIndex, rec, fail) {
1755
1771
  }
1756
1772
 
1757
1773
  /**
1758
- * Шукає **Ingress** у розібраних документах; реєструє порушення.
1774
+ * Чи маніфест використовує заборонений **`apiVersion: autoscaling/v1`** (HPA).
1775
+ * Канон — **`autoscaling/v2`** (див. k8s.mdc).
1776
+ * @param {unknown} manifest корінь YAML-документа
1777
+ * @returns {boolean} true, якщо `apiVersion === 'autoscaling/v1'`
1778
+ */
1779
+ export function isForbiddenAutoscalingV1Manifest(manifest) {
1780
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
1781
+ return false
1782
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
1783
+ return rec.apiVersion === 'autoscaling/v1'
1784
+ }
1785
+
1786
+ /**
1787
+ * Заборонена група **`apiVersion: autoscaling/v1`** (HPA) — вимагається міграція на **`autoscaling/v2`**.
1788
+ * @param {string} rel відносний шлях до файлу
1789
+ * @param {number} docIndex 1-based індекс документа
1790
+ * @param {Record<string, unknown>} rec корінь маніфесту
1791
+ * @param {(msg: string) => void} fail реєстрація помилки
1792
+ * @returns {void}
1793
+ */
1794
+ function failIfAutoscalingV1InDocument(rel, docIndex, rec, fail) {
1795
+ if (!isForbiddenAutoscalingV1Manifest(rec)) {
1796
+ return
1797
+ }
1798
+ const kind = typeof rec.kind === 'string' ? rec.kind : '(невідомо)'
1799
+ fail(
1800
+ `${rel}: знайдено apiVersion: autoscaling/v1 (документ ${docIndex}, kind: ${kind}) — мігруй на autoscaling/v2 (див. k8s.mdc)`
1801
+ )
1802
+ }
1803
+
1804
+ /**
1805
+ * Шукає заборонені маніфести у розібраних документах: **kind: Ingress** і **apiVersion: autoscaling/v1**.
1759
1806
  * @param {string} rel відносний шлях до файлу
1760
1807
  * @param {string} body YAML після modeline
1761
- * @param {(msg: string) => void} fail callback для помилки (Ingress)
1808
+ * @param {(msg: string) => void} fail callback для помилки
1762
1809
  * @returns {void}
1763
1810
  */
1764
- function scanIngressInYamlDocuments(rel, body, fail) {
1811
+ function scanForbiddenManifestsInYamlDocuments(rel, body, fail) {
1765
1812
  /** @type {import('yaml').Document[]} */
1766
1813
  let docs
1767
1814
  try {
@@ -1774,14 +1821,35 @@ function scanIngressInYamlDocuments(rel, body, fail) {
1774
1821
  if (doc.errors.length === 0) {
1775
1822
  const obj = doc.toJSON()
1776
1823
  if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
1777
- failIfIngressInDocument(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
1824
+ const rec = /** @type {Record<string, unknown>} */ (obj)
1825
+ failIfIngressInDocument(rel, di + 1, rec, fail)
1826
+ failIfAutoscalingV1InDocument(rel, di + 1, rec, fail)
1778
1827
  }
1779
1828
  }
1780
1829
  }
1781
1830
  }
1782
1831
 
1783
1832
  /**
1784
- * Перевірка поля **resources** для одного контейнера **Deployment**.
1833
+ * Рекомендоване значення **`resources.requests.cpu`** за замовчуванням для підказки в повідомленнях (k8s.mdc).
1834
+ */
1835
+ export const DEFAULT_CONTAINER_CPU_REQUEST = '0.5'
1836
+
1837
+ /**
1838
+ * Чи значення `resources.requests.cpu` записане у валідному вигляді:
1839
+ * непорожній рядок (`"500m"`, `"0.5"`) або додатне число.
1840
+ * @param {unknown} cpu значення поля `resources.requests.cpu`
1841
+ * @returns {boolean} true, якщо значення прийнятне
1842
+ */
1843
+ function isValidCpuRequestValue(cpu) {
1844
+ if (typeof cpu === 'string') return cpu.trim() !== ''
1845
+ if (typeof cpu === 'number') return Number.isFinite(cpu) && cpu > 0
1846
+ return false
1847
+ }
1848
+
1849
+ /**
1850
+ * Перевірка поля **`resources`** для одного контейнера **Deployment**: вимагає не лише присутність
1851
+ * **`resources`**, а й непорожнє **`resources.requests.cpu`** (див. k8s.mdc). Якщо конкретне
1852
+ * значення ще не обрано — як безпечне за замовчуванням рекомендовано **`DEFAULT_CONTAINER_CPU_REQUEST`**.
1785
1853
  * @param {unknown} c елемент **containers[]**
1786
1854
  * @param {string} label підпис у повідомленні
1787
1855
  * @returns {string | null} текст порушення або null
@@ -1792,11 +1860,23 @@ function deploymentContainerResourcesViolation(c, label) {
1792
1860
  }
1793
1861
  const cont = /** @type {Record<string, unknown>} */ (c)
1794
1862
  if (!('resources' in cont)) {
1795
- return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
1863
+ return `контейнер "${label}": відсутнє поле resources — додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
1796
1864
  }
1797
1865
  const r = cont.resources
1798
1866
  if (r === null || typeof r !== 'object' || Array.isArray(r)) {
1799
- return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
1867
+ return `контейнер "${label}": resources має бути записом у YAML`
1868
+ }
1869
+ const resources = /** @type {Record<string, unknown>} */ (r)
1870
+ const requests = resources.requests
1871
+ if (requests === null || requests === undefined || typeof requests !== 'object' || Array.isArray(requests)) {
1872
+ return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
1873
+ }
1874
+ const req = /** @type {Record<string, unknown>} */ (requests)
1875
+ if (!('cpu' in req)) {
1876
+ return `контейнер "${label}": додай resources.requests.cpu (за замовчуванням ${DEFAULT_CONTAINER_CPU_REQUEST}) (див. k8s.mdc)`
1877
+ }
1878
+ if (!isValidCpuRequestValue(req.cpu)) {
1879
+ return `контейнер "${label}": resources.requests.cpu має бути непорожнім значенням (наприклад "500m" або ${DEFAULT_CONTAINER_CPU_REQUEST}) (зараз: ${JSON.stringify(req.cpu)}) (див. k8s.mdc)`
1800
1880
  }
1801
1881
  return null
1802
1882
  }
@@ -1959,6 +2039,49 @@ export function isHasuraDeploymentManifest(manifest) {
1959
2039
  return containerListHasHasuraImage(p.containers) || containerListHasHasuraImage(p.initContainers)
1960
2040
  }
1961
2041
 
2042
+ /**
2043
+ * Обов'язковий ключ у **`data`** ConfigMap для Hasura-Deployment (узгоджено з k8s.mdc).
2044
+ */
2045
+ export const HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY = 'HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS'
2046
+
2047
+ /**
2048
+ * Чи значення поля `data.<key>` у ConfigMap читається як логічне **true**.
2049
+ * ConfigMap у Kubernetes тримає значення як рядки, але в YAML часто пишуть без лапок —
2050
+ * тому приймаємо і булевий **true**, і рядок **"true"** (без регістрової залежності).
2051
+ * @param {unknown} v значення з `data[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY]`
2052
+ * @returns {boolean} true, якщо значення — `true` або рядок `'true'`
2053
+ */
2054
+ function isConfigMapValueTrue(v) {
2055
+ if (v === true) return true
2056
+ if (typeof v === 'string' && v.trim().toLowerCase() === 'true') return true
2057
+ return false
2058
+ }
2059
+
2060
+ /**
2061
+ * Чи порушує ConfigMap вимогу щодо **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS: "true"`** (k8s.mdc).
2062
+ * Перевірка застосовна, коли в тому ж каталозі є Hasura-Deployment (див. `isHasuraDeploymentManifest`).
2063
+ * @param {unknown} manifest корінь YAML-документа ConfigMap
2064
+ * @returns {string | null} текст порушення або null, якщо не ConfigMap / ключ є і значення `true`
2065
+ */
2066
+ export function hasuraConfigMapRemoteSchemaPermissionsViolation(manifest) {
2067
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
2068
+ return null
2069
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
2070
+ if (rec.kind !== 'ConfigMap') return null
2071
+ const data = rec.data
2072
+ if (data === null || data === undefined || typeof data !== 'object' || Array.isArray(data)) {
2073
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
2074
+ }
2075
+ const d = /** @type {Record<string, unknown>} */ (data)
2076
+ if (!Object.hasOwn(d, HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY)) {
2077
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: додай ключ зі значенням "true" (Deployment з hasura/graphql-engine — див. k8s.mdc)`
2078
+ }
2079
+ if (!isConfigMapValueTrue(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])) {
2080
+ return `data.${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}: значення має бути "true" (зараз: ${JSON.stringify(d[HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY])}) (див. k8s.mdc)`
2081
+ }
2082
+ return null
2083
+ }
2084
+
1962
2085
  const K8S_YAML_EXT_RE = /\.ya?ml$/iu
1963
2086
 
1964
2087
  /**
@@ -3059,7 +3182,7 @@ function runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeMa
3059
3182
  */
3060
3183
  function checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
3061
3184
  const body = lines.join('\n')
3062
- scanIngressInYamlDocuments(rel, body, fail)
3185
+ scanForbiddenManifestsInYamlDocuments(rel, body, fail)
3063
3186
  pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
3064
3187
  runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
3065
3188
  }
@@ -3089,7 +3212,7 @@ function checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pa
3089
3212
 
3090
3213
  const body = yamlBodyAfterModeline(lines)
3091
3214
 
3092
- scanIngressInYamlDocuments(rel, body, fail)
3215
+ scanForbiddenManifestsInYamlDocuments(rel, body, fail)
3093
3216
 
3094
3217
  if (schemaUrl.startsWith('file:')) {
3095
3218
  pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
@@ -3305,6 +3428,460 @@ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail,
3305
3428
  }
3306
3429
  }
3307
3430
 
3431
+ /**
3432
+ * Знаходить перший документ **ConfigMap** у файлі (з `metadata.name`).
3433
+ * @param {string} absPath абсолютний шлях до YAML-файлу
3434
+ * @returns {Promise<Record<string, unknown> | null>} об'єкт ConfigMap або null
3435
+ */
3436
+ async function readFirstConfigMapDoc(absPath) {
3437
+ let raw
3438
+ try {
3439
+ raw = await readFile(absPath, 'utf8')
3440
+ } catch {
3441
+ return null
3442
+ }
3443
+ let docs
3444
+ try {
3445
+ docs = parseAllDocuments(raw)
3446
+ } catch {
3447
+ return null
3448
+ }
3449
+ for (const doc of docs) {
3450
+ if (doc.errors.length > 0) continue
3451
+ const obj = doc.toJSON()
3452
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3453
+ const rec = /** @type {Record<string, unknown>} */ (obj)
3454
+ if (rec.kind === 'ConfigMap') return rec
3455
+ }
3456
+ }
3457
+ return null
3458
+ }
3459
+
3460
+ /**
3461
+ * Для кожного `k8s/base/configmap.yaml`, у каталозі якого поруч є Hasura-Deployment,
3462
+ * вимагає у `data` ключ **`HASURA_GRAPHQL_ENABLE_REMOTE_SCHEMA_PERMISSIONS`** зі значенням **`"true"`** (k8s.mdc).
3463
+ * @param {string} root корінь репозиторію
3464
+ * @param {string[]} yamlFilesAbs yaml під k8s
3465
+ * @param {(msg: string) => void} fail callback при помилці
3466
+ * @param {(msg: string) => void} passFn callback при успіху
3467
+ */
3468
+ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs, fail, passFn) {
3469
+ const cmFiles = yamlFilesAbs.filter(abs => {
3470
+ const rel = relative(root, abs).replaceAll('\\', '/')
3471
+ return CONFIGMAP_BASE_PATH_RE.test(`/${rel}`) || rel === 'k8s/base/configmap.yaml'
3472
+ })
3473
+ for (const cmAbs of cmFiles) {
3474
+ const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
3475
+ const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3476
+ if (deployment === null || !isHasuraDeploymentManifest(deployment)) continue
3477
+ const cm = await readFirstConfigMapDoc(cmAbs)
3478
+ if (cm === null) continue
3479
+ const violation = hasuraConfigMapRemoteSchemaPermissionsViolation(cm)
3480
+ if (violation !== null) {
3481
+ fail(`${rel}: ${violation}`)
3482
+ } else {
3483
+ passFn(`${rel}: ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" для Hasura-Deployment (k8s.mdc)`)
3484
+ }
3485
+ }
3486
+ }
3487
+
3488
+ /**
3489
+ * Ім'я файлу HPA поруч із Deployment (див. k8s.mdc).
3490
+ */
3491
+ export const HPA_FILENAME = 'hpa.yaml'
3492
+
3493
+ /**
3494
+ * Ім'я файлу PDB поруч із Deployment (див. k8s.mdc).
3495
+ */
3496
+ export const PDB_FILENAME = 'pdb.yaml'
3497
+
3498
+ /**
3499
+ * Канонічний topologyKey для **topologySpreadConstraints** у Deployment (див. k8s.mdc).
3500
+ */
3501
+ const TOPOLOGY_SPREAD_TOPOLOGY_KEY = 'kubernetes.io/hostname'
3502
+
3503
+ /**
3504
+ * Витягує сегмент каталогу після `/k8s/` у relative-шляху (перший компонент за `k8s/`).
3505
+ * Приклад: `app/k8s/base/deploy.yaml` → `base`; `app/k8s/tr-qa/hpa.yaml` → `tr-qa`.
3506
+ * @param {string} relPath відносний шлях у POSIX-форматі (через `/`)
3507
+ * @returns {string | null} сегмент середовища або null, якщо `/k8s/` немає в шляху
3508
+ */
3509
+ export function k8sEnvSegmentFromRelPath(relPath) {
3510
+ const m = relPath.match(/(?:^|\/)k8s\/([^/]+)(?:\/|$)/u)
3511
+ return m ? m[1] : null
3512
+ }
3513
+
3514
+ /**
3515
+ * Чи сегмент середовища вважається **dev-like** (м'які вимоги до HPA/PDB):
3516
+ * `base`, `dev`, або будь-що з суфіксом `-qa` (напр. `tr-qa`).
3517
+ * Решта (прод / staging / будь-який інший overlay) — прод-вимоги.
3518
+ * @param {string | null | undefined} segment сегмент після `/k8s/`
3519
+ * @returns {boolean} true для dev-like середовища
3520
+ */
3521
+ export function isDevLikeK8sEnvSegment(segment) {
3522
+ if (typeof segment !== 'string' || segment === '') return false
3523
+ if (segment === 'base' || segment === 'dev') return true
3524
+ return /-qa$/u.test(segment)
3525
+ }
3526
+
3527
+ /**
3528
+ * Витягує рядкове ім'я з `metadata.name` об'єкта Kubernetes.
3529
+ * @param {Record<string, unknown>} manifest корінь маніфесту
3530
+ * @returns {string | null} непорожнє ім'я або null
3531
+ */
3532
+ function manifestMetadataName(manifest) {
3533
+ const meta = manifest.metadata
3534
+ if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) return null
3535
+ const n = /** @type {Record<string, unknown>} */ (meta).name
3536
+ return typeof n === 'string' && n.trim() !== '' ? n : null
3537
+ }
3538
+
3539
+ /**
3540
+ * Витягує мітку `app` з `spec.selector.matchLabels.app` Deployment.
3541
+ * @param {Record<string, unknown>} deployment об'єкт Deployment
3542
+ * @returns {string | null} непорожнє значення `app` або null, якщо не задане
3543
+ */
3544
+ export function deploymentAppLabel(deployment) {
3545
+ const spec = deployment.spec
3546
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return null
3547
+ const selector = /** @type {Record<string, unknown>} */ (spec).selector
3548
+ if (selector === null || typeof selector !== 'object' || Array.isArray(selector)) return null
3549
+ const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
3550
+ if (matchLabels === null || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) return null
3551
+ const app = /** @type {Record<string, unknown>} */ (matchLabels).app
3552
+ return typeof app === 'string' && app.trim() !== '' ? app : null
3553
+ }
3554
+
3555
+ /**
3556
+ * Перетворює значення на ціле число (приймає число або числовий рядок).
3557
+ * @param {unknown} v значення з YAML
3558
+ * @returns {number | null} ціле або null, якщо не читається як ціле
3559
+ */
3560
+ function coerceInteger(v) {
3561
+ if (typeof v === 'number' && Number.isInteger(v)) return v
3562
+ if (typeof v === 'string' && /^-?\d+$/u.test(v.trim())) return Number.parseInt(v, 10)
3563
+ return null
3564
+ }
3565
+
3566
+ /**
3567
+ * Перевіряє **HPA** (`autoscaling/v2`, `HorizontalPodAutoscaler`): структура й env-залежні межі
3568
+ * minReplicas / maxReplicas (**dev-like:** `minReplicas === 1`; **прод:** `minReplicas >= 2`, `maxReplicas >= 2`).
3569
+ * @param {unknown} manifest корінь YAML-документа HPA
3570
+ * @param {string} expectedDeployName очікуване ім'я Deployment у `scaleTargetRef.name`
3571
+ * @param {boolean} isDevLike чи середовище dev-like (base/dev/*-qa)
3572
+ * @returns {string[]} список порушень (порожній — ок)
3573
+ */
3574
+ export function hpaManifestViolations(manifest, expectedDeployName, isDevLike) {
3575
+ /** @type {string[]} */
3576
+ const errs = []
3577
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) {
3578
+ errs.push('HPA має бути обʼєктом YAML')
3579
+ return errs
3580
+ }
3581
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
3582
+ if (rec.kind !== 'HorizontalPodAutoscaler') errs.push(`kind має бути HorizontalPodAutoscaler (зараз: ${JSON.stringify(rec.kind)})`)
3583
+ if (rec.apiVersion !== 'autoscaling/v2') errs.push(`apiVersion має бути autoscaling/v2 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3584
+ const spec = rec.spec
3585
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
3586
+ errs.push('spec відсутній або некоректний')
3587
+ return errs
3588
+ }
3589
+ const s = /** @type {Record<string, unknown>} */ (spec)
3590
+ const str = s.scaleTargetRef
3591
+ if (str === null || str === undefined || typeof str !== 'object' || Array.isArray(str)) {
3592
+ errs.push('spec.scaleTargetRef відсутній')
3593
+ } else {
3594
+ const r = /** @type {Record<string, unknown>} */ (str)
3595
+ if (r.apiVersion !== 'apps/v1') errs.push(`spec.scaleTargetRef.apiVersion має бути apps/v1 (зараз: ${JSON.stringify(r.apiVersion)})`)
3596
+ if (r.kind !== 'Deployment') errs.push(`spec.scaleTargetRef.kind має бути Deployment (зараз: ${JSON.stringify(r.kind)})`)
3597
+ if (r.name !== expectedDeployName)
3598
+ errs.push(`spec.scaleTargetRef.name має бути '${expectedDeployName}' (зараз: ${JSON.stringify(r.name)})`)
3599
+ }
3600
+ const minR = coerceInteger(s.minReplicas)
3601
+ const maxR = coerceInteger(s.maxReplicas)
3602
+ if (minR === null) errs.push('spec.minReplicas має бути цілим числом')
3603
+ if (maxR === null) errs.push('spec.maxReplicas має бути цілим числом')
3604
+ if (minR !== null && maxR !== null && minR > maxR) {
3605
+ errs.push(`spec.minReplicas (${minR}) не може бути більше spec.maxReplicas (${maxR})`)
3606
+ }
3607
+ if (isDevLike) {
3608
+ if (minR !== null && minR !== 1) errs.push(`spec.minReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${minR})`)
3609
+ } else {
3610
+ if (minR !== null && minR < 2) errs.push(`spec.minReplicas для прод середовища має бути мінімум 2 (зараз: ${minR})`)
3611
+ if (maxR !== null && maxR < 2) errs.push(`spec.maxReplicas для прод середовища має бути мінімум 2 (зараз: ${maxR})`)
3612
+ }
3613
+ if (!Array.isArray(s.metrics) || s.metrics.length === 0) {
3614
+ errs.push('spec.metrics має бути непорожнім масивом (наприклад, Resource/cpu/Utilization)')
3615
+ }
3616
+ const behavior = s.behavior
3617
+ if (behavior === null || behavior === undefined || typeof behavior !== 'object' || Array.isArray(behavior)) {
3618
+ errs.push('spec.behavior відсутній (має містити scaleUp і scaleDown)')
3619
+ } else {
3620
+ const b = /** @type {Record<string, unknown>} */ (behavior)
3621
+ for (const key of /** @type {const} */ (['scaleUp', 'scaleDown'])) {
3622
+ const v = b[key]
3623
+ if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) {
3624
+ errs.push(`spec.behavior.${key} відсутній`)
3625
+ continue
3626
+ }
3627
+ const policies = /** @type {Record<string, unknown>} */ (v).policies
3628
+ if (!Array.isArray(policies) || policies.length === 0) {
3629
+ errs.push(`spec.behavior.${key}.policies має бути непорожнім масивом`)
3630
+ }
3631
+ }
3632
+ }
3633
+ return errs
3634
+ }
3635
+
3636
+ /**
3637
+ * Перевіряє **PDB** (`policy/v1`, `PodDisruptionBudget`): структура й env-залежна межа
3638
+ * minAvailable (**dev-like:** `=== 0`; **прод:** `>= 1`).
3639
+ * @param {unknown} manifest корінь YAML-документа PDB
3640
+ * @param {string} expectedAppLabel очікувана мітка `app` у `selector.matchLabels`
3641
+ * @param {boolean} isDevLike чи середовище dev-like (base/dev/*-qa)
3642
+ * @returns {string[]} список порушень (порожній — ок)
3643
+ */
3644
+ export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
3645
+ /** @type {string[]} */
3646
+ const errs = []
3647
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) {
3648
+ errs.push('PDB має бути обʼєктом YAML')
3649
+ return errs
3650
+ }
3651
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
3652
+ if (rec.kind !== 'PodDisruptionBudget') errs.push(`kind має бути PodDisruptionBudget (зараз: ${JSON.stringify(rec.kind)})`)
3653
+ if (rec.apiVersion !== 'policy/v1') errs.push(`apiVersion має бути policy/v1 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3654
+ const spec = rec.spec
3655
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
3656
+ errs.push('spec відсутній або некоректний')
3657
+ return errs
3658
+ }
3659
+ const s = /** @type {Record<string, unknown>} */ (spec)
3660
+ const minA = coerceInteger(s.minAvailable)
3661
+ if (minA === null) {
3662
+ errs.push('spec.minAvailable має бути цілим числом')
3663
+ } else if (isDevLike) {
3664
+ if (minA !== 0) errs.push(`spec.minAvailable для dev-like (base/dev/*-qa) має бути 0 (зараз: ${minA})`)
3665
+ } else if (minA < 1) {
3666
+ errs.push(`spec.minAvailable для прод середовища має бути мінімум 1 (зараз: ${minA})`)
3667
+ }
3668
+ const selector = s.selector
3669
+ if (selector === null || selector === undefined || typeof selector !== 'object' || Array.isArray(selector)) {
3670
+ errs.push('spec.selector відсутній')
3671
+ } else {
3672
+ const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
3673
+ if (matchLabels === null || matchLabels === undefined || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) {
3674
+ errs.push('spec.selector.matchLabels відсутній')
3675
+ } else {
3676
+ const app = /** @type {Record<string, unknown>} */ (matchLabels).app
3677
+ if (app !== expectedAppLabel)
3678
+ errs.push(`spec.selector.matchLabels.app має бути '${expectedAppLabel}' (зараз: ${JSON.stringify(app)})`)
3679
+ }
3680
+ }
3681
+ return errs
3682
+ }
3683
+
3684
+ /**
3685
+ * Перевіряє, що Deployment має канонічний запис у **`spec.template.spec.topologySpreadConstraints`**:
3686
+ * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
3687
+ * `labelSelector.matchLabels.app` збігається з міткою Deployment (див. k8s.mdc).
3688
+ * @param {unknown} manifest корінь YAML-документа Deployment
3689
+ * @param {string} expectedAppLabel очікувана мітка `app`
3690
+ * @returns {string | null} текст порушення або null
3691
+ */
3692
+ export function deploymentTopologySpreadConstraintsViolation(manifest, expectedAppLabel) {
3693
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
3694
+ return null
3695
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
3696
+ if (rec.kind !== 'Deployment') return null
3697
+ const spec = rec.spec
3698
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
3699
+ return 'spec відсутній'
3700
+ const template = /** @type {Record<string, unknown>} */ (spec).template
3701
+ if (template === null || typeof template !== 'object' || Array.isArray(template))
3702
+ return 'spec.template відсутній'
3703
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
3704
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec))
3705
+ return 'spec.template.spec відсутній'
3706
+ const tsc = /** @type {Record<string, unknown>} */ (podSpec).topologySpreadConstraints
3707
+ if (!Array.isArray(tsc) || tsc.length === 0) {
3708
+ return `spec.template.spec.topologySpreadConstraints: додай запис maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
3709
+ }
3710
+ for (const item of tsc) {
3711
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
3712
+ const it = /** @type {Record<string, unknown>} */ (item)
3713
+ if (coerceInteger(it.maxSkew) !== 1) continue
3714
+ if (it.topologyKey !== TOPOLOGY_SPREAD_TOPOLOGY_KEY) continue
3715
+ if (it.whenUnsatisfiable !== 'ScheduleAnyway') continue
3716
+ const ls = it.labelSelector
3717
+ if (ls === null || typeof ls !== 'object' || Array.isArray(ls)) continue
3718
+ const ml = /** @type {Record<string, unknown>} */ (ls).matchLabels
3719
+ if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) continue
3720
+ if (/** @type {Record<string, unknown>} */ (ml).app === expectedAppLabel) {
3721
+ return null
3722
+ }
3723
+ }
3724
+ return `spec.template.spec.topologySpreadConstraints: бракує запису maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
3725
+ }
3726
+
3727
+ /**
3728
+ * Збирає всі документи з **k8s**-yaml за заданим `kind` у каталозі.
3729
+ * @param {string} dirPath абсолютний шлях до каталогу
3730
+ * @param {string} kind очікуваний `kind` (наприклад, `HorizontalPodAutoscaler`)
3731
+ * @param {string} [filenameFilter] фільтр за basename (наприклад, `hpa.yaml`); якщо заданий — лише цей файл
3732
+ * @returns {Promise<Record<string, unknown>[]>} список знайдених документів
3733
+ */
3734
+ async function readDocsByKindInDir(dirPath, kind, filenameFilter) {
3735
+ /** @type {Record<string, unknown>[]} */
3736
+ const out = []
3737
+ let entries
3738
+ try {
3739
+ entries = await readdir(dirPath)
3740
+ } catch {
3741
+ return out
3742
+ }
3743
+ for (const entry of entries) {
3744
+ if (filenameFilter !== undefined) {
3745
+ if (entry !== filenameFilter) continue
3746
+ } else if (!K8S_YAML_EXT_RE.test(entry)) {
3747
+ continue
3748
+ }
3749
+ let raw
3750
+ try {
3751
+ raw = await readFile(join(dirPath, entry), 'utf8')
3752
+ } catch {
3753
+ continue
3754
+ }
3755
+ let docs
3756
+ try {
3757
+ docs = parseAllDocuments(raw)
3758
+ } catch {
3759
+ continue
3760
+ }
3761
+ for (const doc of docs) {
3762
+ if (doc.errors.length > 0) continue
3763
+ const obj = doc.toJSON()
3764
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3765
+ const rec = /** @type {Record<string, unknown>} */ (obj)
3766
+ if (rec.kind === kind) out.push(rec)
3767
+ }
3768
+ }
3769
+ }
3770
+ return out
3771
+ }
3772
+
3773
+ /**
3774
+ * Для кожного **Deployment** під `k8s/` перевіряє: у тому ж каталозі повинні бути
3775
+ * `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`), а сам Deployment
3776
+ * повинен мати канонічні **topologySpreadConstraints**. Env-залежні межі (`minReplicas`,
3777
+ * `minAvailable`) — за сегментом після `/k8s/`: `base` / `dev` / `*-qa` = dev-like, решта — прод.
3778
+ * @param {string} root корінь репозиторію
3779
+ * @param {string[]} yamlFilesAbs yaml під k8s
3780
+ * @param {(msg: string) => void} fail callback при помилці
3781
+ * @param {(msg: string) => void} passFn callback при успіху
3782
+ */
3783
+ async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, passFn) {
3784
+ /** @type {Set<string>} */
3785
+ const seenDirs = new Set()
3786
+ for (const abs of yamlFilesAbs) {
3787
+ const dir = dirname(abs)
3788
+ if (seenDirs.has(dir)) continue
3789
+ let raw
3790
+ try {
3791
+ raw = await readFile(abs, 'utf8')
3792
+ } catch {
3793
+ continue
3794
+ }
3795
+ let docs
3796
+ try {
3797
+ docs = parseAllDocuments(raw)
3798
+ } catch {
3799
+ continue
3800
+ }
3801
+ /** @type {Record<string, unknown>[]} */
3802
+ const deployments = []
3803
+ for (const doc of docs) {
3804
+ if (doc.errors.length > 0) continue
3805
+ const obj = doc.toJSON()
3806
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3807
+ const rec = /** @type {Record<string, unknown>} */ (obj)
3808
+ if (rec.kind === 'Deployment') deployments.push(rec)
3809
+ }
3810
+ }
3811
+ if (deployments.length === 0) continue
3812
+ seenDirs.add(dir)
3813
+ const relDir = relative(root, dir).replaceAll('\\', '/')
3814
+ const segment = k8sEnvSegmentFromRelPath(relDir + '/')
3815
+ const isDevLike = isDevLikeK8sEnvSegment(segment)
3816
+ const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
3817
+ const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
3818
+ for (const deployment of deployments) {
3819
+ const deployName = manifestMetadataName(deployment)
3820
+ const appLabel = deploymentAppLabel(deployment)
3821
+ const deployRel = relDir === '' ? '.' : relDir
3822
+ if (deployName === null) {
3823
+ fail(`${deployRel}: Deployment без metadata.name — не можу перевірити HPA/PDB (k8s.mdc)`)
3824
+ continue
3825
+ }
3826
+ if (appLabel === null) {
3827
+ fail(`${deployRel}: Deployment '${deployName}' без spec.selector.matchLabels.app — додай мітку (k8s.mdc)`)
3828
+ continue
3829
+ }
3830
+
3831
+ const tscViolation = deploymentTopologySpreadConstraintsViolation(deployment, appLabel)
3832
+ if (tscViolation !== null) {
3833
+ fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
3834
+ } else {
3835
+ passFn(`${deployRel}: Deployment '${deployName}' має канонічні topologySpreadConstraints (k8s.mdc)`)
3836
+ }
3837
+
3838
+ const hpaRel = `${deployRel}/${HPA_FILENAME}`
3839
+ const matchedHpa = hpaDocs.find(h => {
3840
+ const spec = h.spec
3841
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
3842
+ const str = /** @type {Record<string, unknown>} */ (spec).scaleTargetRef
3843
+ if (str === null || typeof str !== 'object' || Array.isArray(str)) return false
3844
+ return /** @type {Record<string, unknown>} */ (str).name === deployName
3845
+ })
3846
+ if (matchedHpa === undefined) {
3847
+ fail(
3848
+ `${hpaRel}: відсутній або не знайдено HPA зі scaleTargetRef.name='${deployName}' поруч із Deployment (k8s.mdc)`
3849
+ )
3850
+ } else {
3851
+ const hpaErrs = hpaManifestViolations(matchedHpa, deployName, isDevLike)
3852
+ if (hpaErrs.length === 0) {
3853
+ passFn(`${hpaRel}: HPA для Deployment '${deployName}' валідний (k8s.mdc)`)
3854
+ } else {
3855
+ for (const e of hpaErrs) fail(`${hpaRel}: ${e} (k8s.mdc)`)
3856
+ }
3857
+ }
3858
+
3859
+ const pdbRel = `${deployRel}/${PDB_FILENAME}`
3860
+ const matchedPdb = pdbDocs.find(p => {
3861
+ const spec = p.spec
3862
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
3863
+ const selector = /** @type {Record<string, unknown>} */ (spec).selector
3864
+ if (selector === null || typeof selector !== 'object' || Array.isArray(selector)) return false
3865
+ const ml = /** @type {Record<string, unknown>} */ (selector).matchLabels
3866
+ if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) return false
3867
+ return /** @type {Record<string, unknown>} */ (ml).app === appLabel
3868
+ })
3869
+ if (matchedPdb === undefined) {
3870
+ fail(
3871
+ `${pdbRel}: відсутній або не знайдено PDB зі selector.matchLabels.app='${appLabel}' поруч із Deployment (k8s.mdc)`
3872
+ )
3873
+ } else {
3874
+ const pdbErrs = pdbManifestViolations(matchedPdb, appLabel, isDevLike)
3875
+ if (pdbErrs.length === 0) {
3876
+ passFn(`${pdbRel}: PDB для Deployment '${deployName}' валідний (k8s.mdc)`)
3877
+ } else {
3878
+ for (const e of pdbErrs) fail(`${pdbRel}: ${e} (k8s.mdc)`)
3879
+ }
3880
+ }
3881
+ }
3882
+ }
3883
+ }
3884
+
3308
3885
  /**
3309
3886
  * Перевіряє відповідність проєкту правилам k8s.mdc.
3310
3887
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -3348,5 +3925,9 @@ export async function check() {
3348
3925
 
3349
3926
  await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
3350
3927
 
3928
+ await validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFiles, fail, pass)
3929
+
3930
+ await validateDeploymentHpaPdbAndTopology(root, yamlFiles, fail, pass)
3931
+
3351
3932
  return reporter.getExitCode()
3352
3933
  }