@nitra/cursor 1.8.58 → 1.8.63

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/README.md CHANGED
@@ -39,7 +39,7 @@
39
39
 
40
40
  - **Структура Kustomize:** спільне виноситься в **`base`**; вміст **base** відповідає тому, як має виглядати середовище **dev**; окремої директорії **`dev/`** немає — за dev відповідає **`base`**. У інших середовищах — тонкі **overlays** (часто лише **`kustomization.yaml`** і patches / оверрайди).
41
41
  - **Namespace** задається в **`kustomization.yaml`** (`namespace:`), а не через **`metadata.namespace`** у кожному ресурсі; окремі patches лише на зміну **namespace** не потрібні.
42
- - У **Deployment** для кожного контейнера: **`resources`**, **`imagePullPolicy: Always`** (перевіряє **`npx @nitra/cursor check k8s`**).
42
+ - У **Deployment** для кожного контейнера: **`resources`** (перевіряє **`npx @nitra/cursor check k8s`**);
43
43
  - Рядки в **base**, які змінюються в overlays, позначайте коментарем на рядку (узгоджено в команді), наприклад: `# буде замінено через kustomize`.
44
44
  - Після перенесення в **`base`** / overlays **видаляйте** застарілі маніфести та каталоги, які більше не потрібні.
45
45
 
package/mdc/k8s.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.23'
3
+ version: '1.24'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -104,7 +104,7 @@ resources: {}
104
104
 
105
105
  Так маніфест явно резервує місце під **`requests` / `limits`** і уникає випадкового пропуску секції. **`check k8s`** перевіряє це для кожного YAML-документа **`Deployment`** у файлах під **`k8s`**.
106
106
 
107
- У кожному контейнері **`Deployment`** має бути **`imagePullPolicy: Always`** (див. **`check k8s`**).
107
+ **`imagePullPolicy`:** у маніфестах не вимагається; Kubernetes за замовчуванням: образ **без тега** або з **`:latest`** **Always**, з іншим тегом → **IfNotPresent**. **`check k8s`** це поле не перевіряє.
108
108
 
109
109
  Якщо в **`Deployment`** у **`spec.template.spec.containers`** або **`initContainers`** задано образ **`hasura/graphql-engine`**, у полі **`image`** має бути саме **`hasura/graphql-engine:v2.48.15.ubi.amd64`** (для еквівалента Docker Hub допускається префікс **`docker.io/`**). Інші теги або сторонні реєстри з тим самим репозиторієм **`hasura/graphql-engine`** — порушення **`check k8s`**.
110
110
 
@@ -117,6 +117,14 @@ resources: {}
117
117
 
118
118
  Вони потрібні для інтеграції з **Ingress** / класичним балансуванням GKE; після переходу на **Gateway API** їх слід **прибрати** з маніфестів. **`check k8s`** завершиться помилкою, якщо хоча б один із цих ключів присутній.
119
119
 
120
+ ## Service: `svc.yaml` і `svc-hl.yaml` (Gateway API)
121
+
122
+ - У тому самому каталозі, де лежить **`svc.yaml`**, обов’язково має бути **`svc-hl.yaml`**. Якщо файлу ще немає — створи його як копію **`svc.yaml`**: для кожного **Service** задай **`metadata.name`** з суфіксом **`-hl`**, **`spec.clusterIP: None`** (headless), збережи селектор і порти як у кластерного сервісу; **`check k8s`** не генерує файли автоматично.
123
+ - У **`svc.yaml`** кожен **Service** має мати **`spec.type: ClusterIP`**.
124
+ - У **`svc-hl.yaml`** кожен **Service** має мати **`spec.clusterIP: None`** та **`metadata.name`**, що закінчується на **`-hl`**, узгоджено з відповідним ім’ям у **`svc.yaml`** (наприклад **`app`** → **`app-hl`**).
125
+ - У маршрутах **Gateway API** з групи **`gateway.networking.k8s.io`** (**`HTTPRoute`**, **`GRPCRoute`**, **`TCPRoute`**, **`TLSRoute`**, **`UDPRoute`**) посилання **`backendRefs`** / **`backendRef`** на **Service** мають вказувати лише сервіси з цього headless-файлу (ім’я з суфіксом **`-hl`**).
126
+ - Якщо **`svc.yaml`** підключено через **`kustomization.yaml`** (**`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**), у **тому ж** **`kustomization.yaml`** додай посилання на відповідний **`svc-hl.yaml`** (той самий відносний префікс каталогу, що й для **`svc.yaml`**).
127
+
120
128
  ## Kustomize: структура каталогів (`base` / overlays)
121
129
 
122
130
  Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
@@ -176,27 +184,23 @@ resources: {}
176
184
 
177
185
  ## Перевірка
178
186
 
179
- **`npx @nitra/cursor check k8s`** — критерії збігаються з розділом **«Що закодовано в `check-k8s.mjs`»** і з **«Визначення схеми YAML»** вище. Якщо під `k8s` немає `*.yaml` — перевірку пропущено. Інший зміст маніфесту вручну / **`lint-k8s`**.
187
+ **`npx @nitra/cursor check k8s`** — деталі умов у **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**; канон URL **`$schema`** для редактору — розділ **«Визначення схеми YAML»** нижче. Якщо під `k8s` немає `*.yaml` — перевірку пропущено. Решту політик кластера / compliance закриває **`lint-k8s`**.
180
188
 
181
189
  Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape).
182
190
 
183
191
  ## Що закодовано в `check-k8s.mjs`
184
192
 
185
- При зміні правил синхронно оновлюй **`YANNH_PIN`**, **`YANNH_REF`** (якщо зміниться гілка за замовчуванням у репо yannh), **`YANNH_GROUPS`**, **`DATREE_CRD_BASE`** (GitHub Pages каталогу CRD), а в **`run-k8s.mjs`** константу **`KUBERNETES_VERSION`** і **`DATREE_CRD_SCHEMA_LOCATION`** (узгоджено з базою datree у цьому правилі).
193
+ Не дублюй тут повний перелік він у **верхньому JSDoc** **`npm/scripts/check-k8s.mjs`** і в константах (**`YANNH_PIN`**, **`YANNH_GROUPS`**, **`EXPLICIT_K8S_SCHEMAS`**, **`CLUSTER_SCOPED_KINDS`**, **`HASURA_GRAPHQL_ENGINE_IMAGE`** тощо).
194
+
195
+ Коротко: **`$schema`** (один modeline, без **`.yml`** під **`k8s`**), заборона **Ingress**, **Deployment.resources**, пін **hasura/graphql-engine**, **Service** без анотацій NEG/backend-config, пари **`svc.yaml`** / **`svc-hl.yaml`** (**ClusterIP** / headless **`-hl`**) і їхні записи в **`kustomization.yaml`**, **Gateway API** маршрути — **backendRef** лише на **`-hl`**, **`metadata.namespace`** залежно від **base** і графа Kustomize, заборона **`…/k8s/dev/…`**, непорожній **`namespace:`** у **`k8s/base/kustomization.yaml`**, видалення файлів, де всі документи — лише **BackendConfig** (змішані файли — помилка).
186
196
 
187
- - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
188
- - **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; далі перевіряється **`EXPLICIT_K8S_SCHEMAS`** (`Map`: `apiVersion` + `kind` + `type`, для записів без `type` у маніфесті — третій компонент **`*`**); потім `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree (GitHub Pages), крім рядків явної таблиці (наприклад **InfisicalSecret** — raw на `main`).
189
- - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**); у кожного контейнера — **`imagePullPolicy: Always`**.
190
- - **Namespace у маніфестах (не ім’я `kustomization`):** ресурсні YAML у **`…/k8s/base/`** — **завжди** непорожній **`metadata.namespace`**. Інакше будується множина шляхів, досяжних з **`kustomization.yaml`** через **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**, з рекурсією в каталоги з дочірнім **`kustomization.yaml`**: для таких файлів **поза** **`k8s/base`** — **заборона** **`metadata.namespace`**; для файлів поза **`k8s/base`** і поза множиною — **вимога** непорожнього **`metadata.namespace`** (крім **`CLUSTER_SCOPED_KINDS`**).
191
- - Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
192
- - Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
193
- - Якщо в дереві є **`k8s/base/kustomization.yaml`**, у першому документі **завжди** має бути непорожнє поле **`namespace`** (типово **`dev`**; див. розділ **Namespace**).
197
+ При зміні **PIN** версії Kubernetes оновлюй узгоджено **`check-k8s.mjs`**, **`run-k8s.mjs`** (**`KUBERNETES_VERSION`**, **`DATREE_CRD_SCHEMA_LOCATION`**) і розділи **lint-k8s** / **Визначення схеми YAML** у цьому файлі (**`YANNH_REF`**, **`YANNH_GROUPS`**, **`DATREE_CRD_BASE`** — за потреби в скрипті).
194
198
 
195
199
  ## Коли застосовувати (агентам)
196
200
 
197
201
  - Зміни в k8s YAML — після правок **`check k8s`**.
198
202
  - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
199
- - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**); додай **`imagePullPolicy: Always`** для кожного контейнера.
203
+ - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**). **`imagePullPolicy`** за потреби не дублюй — достатньо політики Kubernetes за тегом образу.
200
204
  - Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay). У **`base/kustomization.yaml`** завжди задавай непорожній **`namespace:`**. У **`k8s/base`** у кожному ресурсному YAML має бути явний **`metadata.namespace`**. Поза **base**, якщо не хочеш **`metadata.namespace`** у файлі — підключи його до kustomization (**`resources`** / **`patches`** тощо); інакше додай явний **`metadata.namespace`**.
201
205
  - Після міграції на нову структуру **видали** застарілі файли та каталоги, які вже замінені (див. **Міграція зі старої структури**).
202
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.58",
3
+ "version": "1.8.63",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
9
9
  * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
10
- * порожній **`{}`**) та **`imagePullPolicy: Always`**. Якщо серед **`containers`** / **`initContainers`**
11
- * є образ **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`** (див. k8s.mdc).
10
+ * порожній **`{}`**). Поле **`imagePullPolicy`** не перевіряється діють типові правила Kubernetes
11
+ * (`:latest` або без тега **Always**, інші теги → **IfNotPresent**). Якщо серед **`containers`** /
12
+ * **`initContainers`** є образ **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`**
13
+ * (див. k8s.mdc).
12
14
  *
13
15
  * **Namespace і Kustomize:** YAML у **`…/k8s/base/`** (окрім імені **`kustomization.yaml`**)
14
16
  * завжди має **непорожній** **`metadata.namespace`** у відповідних документах (узгоджено з dev у репозиторії),
@@ -19,9 +21,22 @@
19
21
  *
20
22
  * **`kind: Ingress`** заборонено (потрібен перехід на Gateway API).
21
23
  *
24
+ * Файли під **`k8s`**, де всі YAML-документи — лише **`kind: BackendConfig`**, **видаляються** автоматично.
25
+ * Якщо **BackendConfig** змішано з іншими ресурсами в одному файлі — перевірка завершується помилкою (розділи маніфести).
26
+ *
22
27
  * У **`kind: Service`** у **`metadata.annotations`** не повинно бути ключів **`cloud.google.com/neg`**
23
28
  * та **`cloud.google.com/backend-config`** (див. k8s.mdc).
24
29
  *
30
+ * Файли **`svc.yaml`** / **`svc-hl.yaml`** у **одному каталозі** (див. k8s.mdc): для кожного **`svc.yaml`**
31
+ * поруч обов’язковий **`svc-hl.yaml`** (headless-копія: той самий селектор/порти, **`metadata.name`** з суфіксом **`-hl`**,
32
+ * **`spec.clusterIP: None`**). У **`svc.yaml`** кожен **Service** має **`spec.type: ClusterIP`**. У **`svc-hl.yaml`**
33
+ * кожен **Service** — **`spec.clusterIP: None`** та ім’я на **`-hl`**. У маршрутах **Gateway API**
34
+ * (**`HTTPRoute`**, **`GRPCRoute`**, **`TCPRoute`**, **`TLSRoute`**, **`UDPRoute`**, група **`gateway.networking.k8s.io`**)
35
+ * посилання **`backendRefs` / `backendRef`** на **Service** мають вказувати лише сервіси з суфіксом **`-hl`** у **`name`**.
36
+ * Якщо **`kustomization.yaml`** посилається на **`svc.yaml`** (**`resources`**, **`bases`**, **`components`**, **`crds`**,
37
+ * **`patches[].path`**, **`patchesStrategicMerge`**), у **тому ж** файлі має бути посилання на відповідний **`svc-hl.yaml`**
38
+ * в **тому ж каталозі**, що й **`svc.yaml`** (логіка збігається з **`pathsFromKustomizationObject`**).
39
+ *
25
40
  * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
26
41
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
27
42
  *
@@ -31,7 +46,7 @@
31
46
  * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
32
47
  */
33
48
  import { existsSync } from 'node:fs'
34
- import { readFile, stat } from 'node:fs/promises'
49
+ import { readFile, stat, unlink } from 'node:fs/promises'
35
50
  import { basename, dirname, join, relative, resolve } from 'node:path'
36
51
 
37
52
  import { parseAllDocuments } from 'yaml'
@@ -291,6 +306,79 @@ function pathsFromKustomizationObject(obj) {
291
306
  return out
292
307
  }
293
308
 
309
+ /**
310
+ * Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
311
+ * (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
312
+ * @param {string} kustomizationDir абсолютний шлях до каталогу з **`kustomization.yaml`**
313
+ * @param {string[]} pathRefs рядки з **`pathsFromKustomizationObject`**
314
+ * @returns {string | null} текст порушення або null, якщо ок
315
+ */
316
+ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, pathRefs) {
317
+ /** @type {Set<string>} */
318
+ const resolved = new Set()
319
+ for (const ref of pathRefs) {
320
+ if (typeof ref === 'string' && !ref.includes('://')) {
321
+ resolved.add(resolve(kustomizationDir, ref))
322
+ }
323
+ }
324
+ for (const ref of pathRefs) {
325
+ if (typeof ref === 'string' && !ref.includes('://')) {
326
+ const abs = resolve(kustomizationDir, ref)
327
+ if (basename(abs).toLowerCase() === 'svc.yaml') {
328
+ const hlAbs = resolve(dirname(abs), 'svc-hl.yaml')
329
+ if (!resolved.has(hlAbs)) {
330
+ return `kustomization посилається на «${ref}» — додай у тому ж kustomization.yaml посилання на відповідний svc-hl.yaml (очікуваний шлях поруч, напр. той самий префікс каталогу + svc-hl.yaml; див. k8s.mdc)`
331
+ }
332
+ }
333
+ }
334
+ }
335
+ return null
336
+ }
337
+
338
+ /**
339
+ * Перевіряє всі **`kustomization.yaml`** під **`k8s`**: разом із **`svc.yaml`** має бути **`svc-hl.yaml`** у полях шляхів.
340
+ * @param {string} root корінь репозиторію
341
+ * @param {string[]} yamlFiles абсолютні шляхи до yaml під k8s
342
+ * @param {(msg: string) => void} fail callback помилки
343
+ * @returns {Promise<void>}
344
+ */
345
+ async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
346
+ for (const kustAbs of yamlFiles) {
347
+ if (basename(kustAbs).toLowerCase() === 'kustomization.yaml') {
348
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
349
+ let raw
350
+ try {
351
+ raw = await readFile(kustAbs, 'utf8')
352
+ } catch (error) {
353
+ const msg = error instanceof Error ? error.message : String(error)
354
+ fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
355
+ }
356
+ if (raw !== undefined) {
357
+ const lines = toLines(raw)
358
+ const body = yamlBodyAfterModeline(lines)
359
+ /** @type {import('yaml').Document[] | undefined} */
360
+ let docs
361
+ try {
362
+ docs = parseAllDocuments(body)
363
+ } catch {
364
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
365
+ }
366
+ if (docs !== undefined) {
367
+ const first = docs[0]?.toJSON()
368
+ if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
369
+ const pathRefs = pathsFromKustomizationObject(first)
370
+ const kustDir = dirname(kustAbs)
371
+ const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
372
+ if (v !== null) {
373
+ fail(`${rel}: ${v}`)
374
+ }
375
+ }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+
294
382
  /**
295
383
  * Збирає відносні шляхи (posix) до YAML, підключених до Kustomize з будь-якого **`kustomization.yaml`** під `k8s`.
296
384
  * Обходить **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**;
@@ -420,6 +508,88 @@ async function findK8sYamlFiles(root) {
420
508
  return [...out].sort((a, b) => a.localeCompare(b))
421
509
  }
422
510
 
511
+ /**
512
+ * Тіло YAML для політик (Ingress, BackendConfig тощо): якщо перший рядок — modeline `$schema`, береться вміст після нього.
513
+ * @param {string[]} lines рядки файлу
514
+ * @returns {string} фрагмент для `parseAllDocuments`
515
+ */
516
+ function k8sYamlBodyForDocumentParse(lines) {
517
+ if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
518
+ return yamlBodyAfterModeline(lines)
519
+ }
520
+ return lines.join('\n')
521
+ }
522
+
523
+ /**
524
+ * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
525
+ * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
526
+ * @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed — не вдалося розпарсити YAML
527
+ */
528
+ export function classifyBackendConfigManifestPresence(body) {
529
+ /** @type {import('yaml').Document[]} */
530
+ let docs
531
+ try {
532
+ docs = parseAllDocuments(body)
533
+ } catch {
534
+ return 'unparsed'
535
+ }
536
+
537
+ let hasBc = false
538
+ let hasOther = false
539
+ for (const doc of docs) {
540
+ if (doc.errors.length === 0) {
541
+ const obj = doc.toJSON()
542
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
543
+ const kind = obj.kind
544
+ if (kind === 'BackendConfig') {
545
+ hasBc = true
546
+ } else if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
547
+ hasOther = true
548
+ }
549
+ }
550
+ }
551
+ }
552
+
553
+ if (!hasBc) return 'none'
554
+ if (hasOther) return 'mixed'
555
+ return 'only'
556
+ }
557
+
558
+ /**
559
+ * Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли — `fail`.
560
+ * @param {string} root корінь репозиторію
561
+ * @param {(msg: string) => void} fail реєстрація порушення
562
+ * @param {(msg: string) => void} pass реєстрація успіху
563
+ * @returns {Promise<void>}
564
+ */
565
+ async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
566
+ const yamlFiles = await findK8sYamlFiles(root)
567
+ for (const abs of yamlFiles) {
568
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
569
+ try {
570
+ const raw = await readFile(abs, 'utf8')
571
+ const lines = toLines(raw)
572
+ const body = k8sYamlBodyForDocumentParse(lines)
573
+ const bcPresence = classifyBackendConfigManifestPresence(body)
574
+
575
+ if (bcPresence === 'mixed') {
576
+ fail(
577
+ `${rel}: у файлі разом BackendConfig та інші kind — винеси BackendConfig окремо або прибери вручну; автоматичне видалення не застосовується (див. k8s.mdc)`
578
+ )
579
+ } else if (bcPresence === 'only') {
580
+ try {
581
+ await unlink(abs)
582
+ pass(`${rel}: видалено (лише kind: BackendConfig; див. k8s.mdc)`)
583
+ } catch (error) {
584
+ fail(`${rel}: не вдалося видалити BackendConfig-файл (${error.message})`)
585
+ }
586
+ }
587
+ } catch (error) {
588
+ fail(`${rel}: не вдалося прочитати для перевірки BackendConfig (${error.message})`)
589
+ }
590
+ }
591
+ }
592
+
423
593
  /**
424
594
  * Прибирає BOM і ділить на рядки.
425
595
  * @param {string} content вміст файлу
@@ -441,6 +611,37 @@ function yamlBodyAfterModeline(lines) {
441
611
  return lines.slice(i).join('\n')
442
612
  }
443
613
 
614
+ /**
615
+ * Читає k8s YAML і повертає фрагмент після modeline `$schema`, якщо перший рядок — modeline.
616
+ * Потрібно для парної перевірки **`svc.yaml`** / **`svc-hl.yaml`**.
617
+ * @param {string} abs абсолютний шлях до файлу
618
+ * @returns {Promise<string>} тіло для `parseAllDocuments`
619
+ */
620
+ async function readK8sYamlBodyAfterModelineForSvcPair(abs) {
621
+ const raw = await readFile(abs, 'utf8')
622
+ const lines = toLines(raw)
623
+ if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
624
+ return yamlBodyAfterModeline(lines)
625
+ }
626
+ return lines.join('\n')
627
+ }
628
+
629
+ /**
630
+ * Розбирає YAML на корені документів-об’єктів (ігнорує зламані документи).
631
+ * @param {string} body фрагмент YAML
632
+ * @returns {unknown[]} масив об’єктів-документів
633
+ */
634
+ function parseK8sYamlDocumentObjectRoots(body) {
635
+ try {
636
+ return parseAllDocuments(body)
637
+ .filter(d => d.errors.length === 0)
638
+ .map(d => d.toJSON())
639
+ .filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
640
+ } catch {
641
+ return []
642
+ }
643
+ }
644
+
444
645
  /**
445
646
  * Перший YAML-документ (до наступного `---` на окремому рядку).
446
647
  * @param {string} body фрагмент YAML
@@ -559,42 +760,6 @@ export function deploymentResourcesViolation(manifest) {
559
760
  return null
560
761
  }
561
762
 
562
- /**
563
- * Чи контейнери **Deployment** мають **`imagePullPolicy: Always`** (k8s.mdc).
564
- * @param {unknown} manifest корінь YAML-документа
565
- * @returns {string | null} текст порушення або null, якщо не Deployment / ок
566
- */
567
- export function deploymentImagePullPolicyViolation(manifest) {
568
- if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
569
- return null
570
- const rec = /** @type {Record<string, unknown>} */ (manifest)
571
- if (rec.kind !== 'Deployment') return null
572
- const spec = rec.spec
573
- if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
574
- const template = /** @type {Record<string, unknown>} */ (spec).template
575
- if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template))
576
- return null
577
- const podSpec = /** @type {Record<string, unknown>} */ (template).spec
578
- if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
579
- const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
580
- if (!Array.isArray(containers)) return null
581
-
582
- for (const [i, c] of containers.entries()) {
583
- const label =
584
- typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
585
- ? c.name
586
- : `#${i + 1}`
587
- if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
588
- const cont = /** @type {Record<string, unknown>} */ (c)
589
- if (cont.imagePullPolicy !== 'Always') {
590
- return `контейнер "${label}": imagePullPolicy має бути Always (див. k8s.mdc)`
591
- }
592
- }
593
- }
594
-
595
- return null
596
- }
597
-
598
763
  /**
599
764
  * Прибирає digest з посилання на образ (`@sha256:…`) для порівняння тега.
600
765
  * @param {string} image значення поля `image`
@@ -694,6 +859,287 @@ export function serviceForbiddenGcpAnnotationsViolation(manifest) {
694
859
  return `metadata.annotations: прибери заборонені ключі GKE: ${found.join(', ')} (див. k8s.mdc)`
695
860
  }
696
861
 
862
+ /** Суфікс **`metadata.name`** headless-сервісу поруч із **`svc.yaml`** (див. k8s.mdc). */
863
+ const SVC_HL_NAME_SUFFIX = '-hl'
864
+
865
+ /**
866
+ * Kind маршрутів Gateway API, у **`spec`** яких шукаємо **`backendRefs`** / **`backendRef`** до **Service**.
867
+ * @type {Set<string>}
868
+ */
869
+ const GATEWAY_API_ROUTE_KINDS = new Set(['HTTPRoute', 'GRPCRoute', 'TCPRoute', 'TLSRoute', 'UDPRoute'])
870
+
871
+ /** Префікс **`apiVersion`** стандартних ресурсів Gateway API. */
872
+ const GATEWAY_API_GROUP_PREFIX = 'gateway.networking.k8s.io/'
873
+
874
+ /**
875
+ * Чи **Service** у **`svc.yaml`** має **`spec.type: ClusterIP`** (k8s.mdc).
876
+ * @param {unknown} manifest корінь YAML-документа
877
+ * @returns {string | null} текст порушення або null
878
+ */
879
+ export function serviceSvcYamlClusterIpTypeViolation(manifest) {
880
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
881
+ return null
882
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
883
+ if (rec.kind !== 'Service') return null
884
+ const spec = rec.spec
885
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
886
+ return 'Service: додай spec.type: ClusterIP (svc.yaml, див. k8s.mdc)'
887
+ }
888
+ const s = /** @type {Record<string, unknown>} */ (spec)
889
+ if (s.type !== 'ClusterIP') {
890
+ const cur = s.type === undefined ? 'відсутнє' : String(s.type)
891
+ return `Service spec.type має бути ClusterIP (svc.yaml; зараз: ${cur}; див. k8s.mdc)`
892
+ }
893
+ return null
894
+ }
895
+
896
+ /**
897
+ * Чи **Service** у **`svc-hl.yaml`** headless (**`spec.clusterIP: None`**) з суфіксом **`-hl`** у **`metadata.name`**.
898
+ * @param {unknown} manifest корінь YAML-документа
899
+ * @returns {string | null} текст порушення або null
900
+ */
901
+ export function serviceSvcHlYamlHeadlessViolation(manifest) {
902
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
903
+ return null
904
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
905
+ if (rec.kind !== 'Service') return null
906
+ const meta = rec.metadata
907
+ if (meta === null || meta === undefined || typeof meta !== 'object' || Array.isArray(meta)) {
908
+ return 'Service: потрібні metadata.name з суфіксом -hl (svc-hl.yaml, див. k8s.mdc)'
909
+ }
910
+ const m = /** @type {Record<string, unknown>} */ (meta)
911
+ const n = m.name
912
+ if (typeof n !== 'string' || !n.endsWith(SVC_HL_NAME_SUFFIX)) {
913
+ return `Service metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (svc-hl.yaml; див. k8s.mdc)`
914
+ }
915
+ const spec = rec.spec
916
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
917
+ return 'Service: додай spec.clusterIP: None (svc-hl.yaml, див. k8s.mdc)'
918
+ }
919
+ const s = /** @type {Record<string, unknown>} */ (spec)
920
+ if (s.clusterIP !== 'None') {
921
+ const cur = s.clusterIP === undefined ? 'відсутнє' : String(s.clusterIP)
922
+ return `Service spec.clusterIP має бути None (headless, svc-hl.yaml; зараз: ${cur}; див. k8s.mdc)`
923
+ }
924
+ return null
925
+ }
926
+
927
+ /**
928
+ * Чи об’єкт схожий на **backendRef** до **Kubernetes Service** у Gateway API.
929
+ * @param {unknown} obj вузол у дереві **`spec`**
930
+ * @returns {boolean} true, якщо враховуємо поле **`name`** як посилання на Service
931
+ */
932
+ function isGatewayApiBackendRefToService(obj) {
933
+ if (obj === null || obj === undefined || typeof obj !== 'object' || Array.isArray(obj)) return false
934
+ const o = /** @type {Record<string, unknown>} */ (obj)
935
+ if (typeof o.name !== 'string') return false
936
+ const kind = o.kind
937
+ if (kind !== undefined && kind !== 'Service') return false
938
+ const group = o.group
939
+ if (typeof group === 'string' && group !== '' && group !== 'core') return false
940
+ return true
941
+ }
942
+
943
+ /**
944
+ * Збирає імена **Service** з **`backendRefs`** / **`backendRef`** у піддереві **`spec`** маршруту Gateway API.
945
+ * @param {unknown} spec значення **`spec`** маршруту
946
+ * @returns {string[]} імена backend-сервісів (можливі дублікати)
947
+ */
948
+ export function collectGatewayApiRouteBackendServiceNames(spec) {
949
+ /** @type {string[]} */
950
+ const out = []
951
+
952
+ /**
953
+ * @param {unknown} node вузол для обходу
954
+ * @returns {void}
955
+ */
956
+ function walk(node) {
957
+ if (node === null || node === undefined) return
958
+ if (Array.isArray(node)) {
959
+ for (const x of node) {
960
+ walk(x)
961
+ }
962
+ return
963
+ }
964
+ if (typeof node !== 'object') return
965
+ if (isGatewayApiBackendRefToService(node)) {
966
+ out.push(String(/** @type {Record<string, unknown>} */ (node).name))
967
+ }
968
+ for (const v of Object.values(node)) {
969
+ walk(v)
970
+ }
971
+ }
972
+
973
+ walk(spec)
974
+ return out
975
+ }
976
+
977
+ /**
978
+ * Реєструє порушення: маршрути Gateway API мають посилатися на **Service** з суфіксом **`-hl`**.
979
+ * @param {string} rel відносний шлях до файлу
980
+ * @param {string} body YAML після modeline
981
+ * @param {(msg: string) => void} fail callback помилки
982
+ * @returns {void}
983
+ */
984
+ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
985
+ /** @type {import('yaml').Document[]} */
986
+ let docs
987
+ try {
988
+ docs = parseAllDocuments(body)
989
+ } catch {
990
+ return
991
+ }
992
+
993
+ for (const [di, doc] of docs.entries()) {
994
+ if (doc.errors.length === 0) {
995
+ const obj = doc.toJSON()
996
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
997
+ const rec = /** @type {Record<string, unknown>} */ (obj)
998
+ const av = rec.apiVersion
999
+ const kind = rec.kind
1000
+ if (
1001
+ typeof av === 'string' &&
1002
+ av.startsWith(GATEWAY_API_GROUP_PREFIX) &&
1003
+ typeof kind === 'string' &&
1004
+ GATEWAY_API_ROUTE_KINDS.has(kind)
1005
+ ) {
1006
+ const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
1007
+ for (const svcName of names) {
1008
+ if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
1009
+ fail(
1010
+ `${rel}: Gateway API ${kind} (документ ${di + 1}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
1011
+ )
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ /**
1021
+ * Перевіряє пари **`svc.yaml`** / **`svc-hl.yaml`** у каталозі (наявність, узгоджені імена **Service**).
1022
+ * @param {string} root корінь репозиторію
1023
+ * @param {string[]} yamlFiles абсолютні шляхи до `*.yaml` під `k8s`
1024
+ * @param {(msg: string) => void} fail callback помилки
1025
+ * @returns {Promise<void>}
1026
+ */
1027
+ async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
1028
+ const absSet = new Set(yamlFiles)
1029
+
1030
+ for (const abs of yamlFiles) {
1031
+ if (basename(abs).toLowerCase() === 'svc-hl.yaml') {
1032
+ const svcAbs = join(dirname(abs), 'svc.yaml')
1033
+ if (!absSet.has(svcAbs)) {
1034
+ const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
1035
+ fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ for (const svcAbs of yamlFiles) {
1041
+ if (basename(svcAbs).toLowerCase() === 'svc.yaml') {
1042
+ const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
1043
+ const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
1044
+ if (absSet.has(hlAbs)) {
1045
+ /** @type {string | undefined} */
1046
+ let svcBody
1047
+ /** @type {string | undefined} */
1048
+ let hlBody
1049
+ try {
1050
+ svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
1051
+ hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
1052
+ } catch (error) {
1053
+ const msg = error instanceof Error ? error.message : String(error)
1054
+ fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
1055
+ }
1056
+ if (svcBody !== undefined && hlBody !== undefined) {
1057
+ const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
1058
+ const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
1059
+
1060
+ /** @type {string[]} */
1061
+ const svcNames = []
1062
+ for (const [i, rootObj] of svcRoots.entries()) {
1063
+ const r = /** @type {Record<string, unknown>} */ (rootObj)
1064
+ if (r.kind === 'Service') {
1065
+ const meta = r.metadata
1066
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1067
+ const nm = /** @type {Record<string, unknown>} */ (meta).name
1068
+ if (typeof nm === 'string') {
1069
+ svcNames.push(nm)
1070
+ } else {
1071
+ fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1072
+ }
1073
+ } else {
1074
+ fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1075
+ }
1076
+ }
1077
+ }
1078
+
1079
+ if (svcNames.length === 0) {
1080
+ fail(`${rel}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1081
+ } else {
1082
+ /** @type {string[]} */
1083
+ const hlNames = []
1084
+ for (const [i, rootObj] of hlRoots.entries()) {
1085
+ const r = /** @type {Record<string, unknown>} */ (rootObj)
1086
+ if (r.kind === 'Service') {
1087
+ const meta = r.metadata
1088
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
1089
+ const nm = /** @type {Record<string, unknown>} */ (meta).name
1090
+ if (typeof nm === 'string') {
1091
+ hlNames.push(nm)
1092
+ } else {
1093
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1094
+ fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
1095
+ }
1096
+ } else {
1097
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1098
+ fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ if (hlNames.length === 0) {
1104
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1105
+ fail(`${hlRel}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
1106
+ } else {
1107
+ const hlSet = new Set(hlNames)
1108
+ for (const n of svcNames) {
1109
+ const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
1110
+ if (!hlSet.has(expectHl)) {
1111
+ fail(
1112
+ `${rel}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
1113
+ )
1114
+ }
1115
+ }
1116
+
1117
+ for (const h of hlNames) {
1118
+ if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
1119
+ const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
1120
+ if (!svcNames.includes(base)) {
1121
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1122
+ fail(
1123
+ `${hlRel}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
1124
+ )
1125
+ }
1126
+ } else {
1127
+ const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
1128
+ fail(
1129
+ `${hlRel}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
1130
+ )
1131
+ }
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+ } else {
1137
+ fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
1138
+ }
1139
+ }
1140
+ }
1141
+ }
1142
+
697
1143
  /**
698
1144
  * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
699
1145
  * @param {unknown} manifest корінь YAML-документа
@@ -762,8 +1208,9 @@ export function isK8sBaseManifestYamlPath(rel, baseLower) {
762
1208
  }
763
1209
 
764
1210
  /**
765
- * Парсить усі YAML-документи: **metadata.namespace**, **Deployment.resources**, **imagePullPolicy**, **Hasura image pin**,
766
- * **Service — заборонені GKE-анотації**.
1211
+ * Парсить усі YAML-документи: **metadata.namespace**, **Deployment.resources**, **Hasura image pin**,
1212
+ * **Service — заборонені GKE-анотації**, **`svc.yaml`** (**`spec.type: ClusterIP`**), **`svc-hl.yaml`**
1213
+ * (**headless**, суфікс **`-hl`** у **`metadata.name`**).
767
1214
  * @param {string} rel відносний шлях
768
1215
  * @param {string} baseLower basename файлу (нижній регістр)
769
1216
  * @param {string} body вміст після modeline
@@ -811,10 +1258,6 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
811
1258
  if (resV !== null) {
812
1259
  fail(`${rel}: Deployment (документ ${di + 1}): ${resV}`)
813
1260
  }
814
- const pullV = deploymentImagePullPolicyViolation(obj)
815
- if (pullV !== null) {
816
- fail(`${rel}: Deployment (документ ${di + 1}): ${pullV}`)
817
- }
818
1261
  const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
819
1262
  if (hasuraV !== null) {
820
1263
  fail(`${rel}: Deployment (документ ${di + 1}): ${hasuraV}`)
@@ -823,6 +1266,18 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
823
1266
  if (svcGcpV !== null) {
824
1267
  fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
825
1268
  }
1269
+ if (baseLower === 'svc.yaml') {
1270
+ const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
1271
+ if (svcT !== null) {
1272
+ fail(`${rel}: Service (документ ${di + 1}): ${svcT}`)
1273
+ }
1274
+ }
1275
+ if (baseLower === 'svc-hl.yaml') {
1276
+ const svcH = serviceSvcHlYamlHeadlessViolation(obj)
1277
+ if (svcH !== null) {
1278
+ fail(`${rel}: Service (документ ${di + 1}): ${svcH}`)
1279
+ }
1280
+ }
826
1281
  }
827
1282
  }
828
1283
  }
@@ -975,6 +1430,8 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
975
1430
 
976
1431
  const kustomizeManaged = kustomizeManagedRel.has(rel)
977
1432
  validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
1433
+
1434
+ scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
978
1435
  }
979
1436
 
980
1437
  /**
@@ -1042,6 +1499,9 @@ export async function check() {
1042
1499
  }
1043
1500
 
1044
1501
  const root = process.cwd()
1502
+
1503
+ await removeBackendConfigOnlyK8sYamlFiles(root, fail, pass)
1504
+
1045
1505
  const yamlFiles = await findK8sYamlFiles(root)
1046
1506
 
1047
1507
  if (yamlFiles.length === 0) {
@@ -1059,6 +1519,10 @@ export async function check() {
1059
1519
  await checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel)
1060
1520
  }
1061
1521
 
1522
+ await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
1523
+
1524
+ await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
1525
+
1062
1526
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1063
1527
 
1064
1528
  return exitCode
@@ -21,6 +21,4 @@ README має бути в директорії **k8s**.
21
21
 
22
22
  Застарілі файли прибирай.
23
23
 
24
- У всіх Deployment має бути `imagePullPolicy: Always`.
25
-
26
24
  Для overlays **ru** та **ua** `namespace` задавай у `kustomization.yaml` (без окремих patch лише на зміну namespace). Деталі — **n-k8s** / **abie** у `.cursor/rules/`, якщо ці правила увімкнені в проєкті.