@nitra/cursor 1.8.61 → 1.8.64

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
@@ -42,7 +42,7 @@ alwaysApply: false
42
42
  }
43
43
  ```
44
44
 
45
- Якщо правило **`k8s`** підключено в **`.n-cursor.json`** (масив **`rules`**), у **кореневому** `package.json` **обов'язково** мають бути скрипт **`lint-k8s`** і виклик **`bun run lint-k8s`** у агрегованому **`lint`** (див. **`bun.mdc`**). Це перевіряє **`npx @nitra/cursor check bun`**.
45
+ Якщо правило **`k8s`** підключено в **`.n-cursor.json`** (масив **`rules`**), у **кореневому** `package.json` **мають** бути скрипт **`lint-k8s`** і виклик **`bun run lint-k8s`** у агрегованому **`lint`** (див. **`bun.mdc`**). Це перевіряє **`npx @nitra/cursor check bun`**.
46
46
 
47
47
  Шлях до скрипта підстав свій (`./scripts/…` після копіювання, `node_modules/@nitra/cursor/scripts/…` якщо пакет у залежностях).
48
48
 
@@ -96,26 +96,25 @@ jobs:
96
96
 
97
97
  ## Deployment: `resources`
98
98
 
99
- Для **`kind: Deployment`** у кожному контейнері **`spec.template.spec.containers[]`** має бути явне поле **`resources`**. Якщо ліміти та requests ще не задані, додай порожній об'єкт:
99
+ У **`Deployment`** у кожному **`containers`** / **`initContainers`** має бути **`resources`**; якщо лімітів ще немає мінімум порожній запис:
100
100
 
101
101
  ```yaml
102
102
  resources: {}
103
103
  ```
104
104
 
105
- Так маніфест явно резервує місце під **`requests` / `limits`** і уникає випадкового пропуску секції. **`check k8s`** перевіряє це для кожного YAML-документа **`Deployment`** у файлах під **`k8s`**.
105
+ **`check k8s`** перевіряє наявність **`resources`** у кожному документі **Deployment** під **`k8s`**. Поле **`imagePullPolicy`** скрипт **не** перевіряє (залишається політиці Kubernetes за тегом образу).
106
106
 
107
- **`imagePullPolicy`:** у маніфестах не вимагається; Kubernetes за замовчуванням: образ **без тега** або з **`:latest`** **Always**, з іншим тегом **IfNotPresent**. **`check k8s`** це поле не перевіряє.
108
-
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`**.
107
+ Образ **`hasura/graphql-engine`**: дозволений лише канонічний тег із константи **`HASURA_GRAPHQL_ENGINE_IMAGE`** у **`check-k8s.mjs`** (допускається префікс **`docker.io/`**); решта помилка **check k8s**.
110
108
 
111
109
  ## Service: заборонені анотації GKE
112
110
 
113
- У **`kind: Service`** не використовуй у **`metadata.annotations`** ключі:
111
+ У **`kind: Service`** не додавай у **`metadata.annotations`** **`cloud.google.com/neg`** і **`cloud.google.com/backend-config`** (legacy під Ingress / старе балансування GKE). **check k8s** падає, якщо ключ є.
112
+
113
+ ## Service: `svc.yaml` і `svc-hl.yaml` (Gateway API)
114
114
 
115
- - **`cloud.google.com/neg`**
116
- - **`cloud.google.com/backend-config`**
115
+ Пара файлів для кластерного й headless **Service**: **`svc.yaml`** + **`svc-hl.yaml`** в одному каталозі, **`spec.type: ClusterIP`** / **`clusterIP: None`**, імена **`-hl`**, узгодженість пар, маршрути **`gateway.networking.k8s.io`** (**HTTPRoute**, **GRPCRoute**, **TCPRoute**, **TLSRoute**, **UDPRoute**) — **backendRef** лише на сервіси з суфіксом **`-hl`**; якщо **kustomization** посилається на **`svc.yaml`**, у **тому ж** **`kustomization.yaml`** має бути посилання на sibling **`svc-hl.yaml`**. Скрипт **не** створює файли — додай **`svc-hl.yaml`** вручну (копія з правками **name** / **clusterIP**).
117
116
 
118
- Вони потрібні для інтеграції з **Ingress** / класичним балансуванням GKE; після переходу на **Gateway API** їх слід **прибрати** з маніфестів. **`check k8s`** завершиться помилкою, якщо хоча б один із цих ключів присутній.
117
+ **Точні умови та повідомлення `fail`** верхній JSDoc **`npm/scripts/check-k8s.mjs`**.
119
118
 
120
119
  ## Kustomize: структура каталогів (`base` / overlays)
121
120
 
@@ -134,11 +133,11 @@ resources: {}
134
133
 
135
134
  ### Namespace
136
135
 
137
- - **`base/kustomization.yaml`:** у цьому файлі поле **`namespace:`** **завжди** має бути присутнє й **непорожнє**, щоб Kustomize застосував один цільовий namespace до ресурсів **base**. **`check k8s`** перевіряє це, коли файл є в репозиторії.
136
+ - **`base/kustomization.yaml`:** поле **`namespace:`** має бути **непорожнім** (перевіряє **check k8s**, якщо файл є).
138
137
 
139
- - **Де не дублювати `metadata.namespace`:** у YAML під **`k8s`**, чиї шляхи **досяжні** з будь-якого **`kustomization.yaml`** через **`resources`**, **`bases`**, **`components`**, **`crds`**, елементи **`patches`** з полем **`path`**, **`patchesStrategicMerge`** (транзитивно, зокрема через каталог із дочірнім **`kustomization.yaml`**). У таких файлах **не** вказуй **`metadata.namespace`** — його виставляє Kustomize за полем **`namespace:`** у kustomization.
138
+ - **Де не дублювати `metadata.namespace`:** у YAML, досяжних через **граф Kustomize** (шляхи з **`kustomization.yaml`**, як у логіці **`collectKustomizeManagedRelPaths`** / **check k8s**). **Namespace** задає **`namespace:`** у kustomization.
140
139
 
141
- - **Коли `metadata.namespace` обов’язковий:** YAML під **`k8s`**, який **ніде** не перелічений у згаданих полях жодного kustomization-файлу, має містити **непорожній** **`metadata.namespace`** у кожному документі з **`apiVersion`** та **`kind`**, **окрім** кластерних ресурсів (наприклад **`Namespace`**, **`ClusterRole`**, **`PersistentVolume`** — повний перелік у **`check-k8s.mjs`**, **`CLUSTER_SCOPED_KINDS`**). Якщо файл має бути без namespace у маніфесті — підключи його до kustomization через **`resources`** / **`patches`** тощо.
140
+ - **Коли `metadata.namespace` обов’язковий у файлі:** YAML під **`k8s`**, який **не** в графі жодного kustomization непорожній **`metadata.namespace`** для namespaced **kind** (винятки кластерні **kind**, перелік **`CLUSTER_SCOPED_KINDS`** у **`check-k8s.mjs`**). Якщо namespace у маніфесті не потрібен — підключи файл через **`resources`** / **`patches`** тощо.
142
141
 
143
142
  - **Не додавай** окремі **patches** Kustomize, які лише змінюють **namespace**: **namespace** визначає Kustomize; у overlays додаткові зміни — без дублювання логіки **namespace**.
144
143
 
@@ -174,27 +173,56 @@ resources: {}
174
173
 
175
174
  **`check k8s`:** заборонено **`kind: Ingress`**.
176
175
 
177
- ## Перевірка
176
+ 3. Якщо **HTTPRoute** посилається на **Service** в іншому **namespace**, потрібен **ReferenceGrant** (дозвіл). Наприклад **HTTPRoute** в **`contract`** і **Service** в **`dev`** — додай маніфест на кшталт **`base/rg.yaml`** (фрагмент нижче).
177
+
178
+ ```yaml title="base/rg.yaml"
179
+ # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/referencegrant_v1beta1.json
180
+ apiVersion: gateway.networking.k8s.io/v1beta1
181
+ kind: ReferenceGrant
182
+ metadata:
183
+ name: contract-to-dev
184
+ namespace: dev
185
+ spec:
186
+ from:
187
+ - group: gateway.networking.k8s.io
188
+ kind: HTTPRoute
189
+ namespace: contract
190
+ to:
191
+ - group: ''
192
+ kind: Service
193
+ # Якщо name не вказано, доступ дозволено до всіх Service в цьому namespace
194
+ ```
178
195
 
179
- **`npx @nitra/cursor check k8s`** деталі умов у **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**; канон URL **`$schema`** для редактору розділ **«Визначення схеми YAML»** нижче. Якщо під `k8s` немає `*.yaml`перевірку пропущено. Решту політик кластера / compliance закриває **`lint-k8s`**.
196
+ **ReferenceGrant** має бути в **namespace** тих **Service**, до яких відкривається доступ (у прикладі **`dev`**). Якщо **`namespace:`** у **overlay** не збігається з **namespace** гранта, **не** додавай **`base/rg.yaml`** у **`resources:`** того overlayKustomize може перезаписати **`metadata.namespace`** гранта. Натомість застосуй **`patches`** (JSON patch), щоб явно виставити потрібний **namespace**:
197
+
198
+ ```yaml title="overlay/kustomization.yaml (фрагмент)"
199
+ patches:
200
+ - target:
201
+ kind: ReferenceGrant
202
+ name: contract-to-dev
203
+ patch: |-
204
+ - op: replace
205
+ path: /metadata/namespace
206
+ value: dev
207
+ ```
208
+
209
+ ## Перевірка
180
210
 
181
- Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape).
211
+ **`npx @nitra/cursor check k8s`** програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
182
212
 
183
- ## Що закодовано в `check-k8s.mjs`
213
+ **Не входить у check k8s:** наприклад **ReferenceGrant** і доступ між **namespace** (лише рекомендації тут), **kubeconform** / **kubescape** — це **`bun run lint-k8s`**.
184
214
 
185
- Не дублюй тут повний перелік — він у **верхньому JSDoc** **`npm/scripts/check-k8s.mjs`** і в константах (**`YANNH_PIN`**, **`YANNH_GROUPS`**, **`EXPLICIT_K8S_SCHEMAS`**, **`CLUSTER_SCOPED_KINDS`**, **`HASURA_GRAPHQL_ENGINE_IMAGE`** тощо).
215
+ ## Що саме в скрипті `check-k8s.mjs`
186
216
 
187
- Коротко: **`$schema`** (один modeline, без **`.yml`** під **`k8s`**), заборона **Ingress**, **Deployment.resources**, пін **hasura/graphql-engine**, **Service** без анотацій NEG/backend-config, **`metadata.namespace`** залежно від **base** і графа Kustomize, заборона **`…/k8s/dev/…`**, непорожній **`namespace:`** у **`k8s/base/kustomization.yaml`**, видалення файлів, де всі документи — лише **BackendConfig** (змішані файли — помилка).
217
+ Повний перелік умов, константи (**`YANNH_PIN`**, **`CLUSTER_SCOPED_KINDS`**, **`HASURA_GRAPHQL_ENGINE_IMAGE`** тощо) і допоміжні функції у файлі скрипта; змінив вимогу для **check** онови **JSDoc** і за потреби тести в **`npm/tests/check-k8s-schema.test.mjs`**.
188
218
 
189
- При зміні **PIN** версії Kubernetes оновлюй узгоджено **`check-k8s.mjs`**, **`run-k8s.mjs`** (**`KUBERNETES_VERSION`**, **`DATREE_CRD_SCHEMA_LOCATION`**) і розділи **lint-k8s** / **Визначення схеми YAML** у цьому файлі (**`YANNH_REF`**, **`YANNH_GROUPS`**, **`DATREE_CRD_BASE`** — за потреби в скрипті).
219
+ При зміні **PIN** версії Kubernetes узгодь **`check-k8s.mjs`**, **`run-k8s.mjs`** (**`KUBERNETES_VERSION`**, **`DATREE_CRD_SCHEMA_LOCATION`**) і цей файл (**lint-k8s**, **Визначення схеми YAML**).
190
220
 
191
221
  ## Коли застосовувати (агентам)
192
222
 
193
- - Зміни в k8s YAML після правок **`check k8s`**.
194
- - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind`не дублюй; змінився ресурс онови лише `$schema`.
195
- - У **`Deployment`** без поля **`resources`** у контейнері додай **`resources: {}`** (див. розділ **Deployment: `resources`**). **`imagePullPolicy`** за потреби не дублюй — достатньо політики Kubernetes за тегом образу.
196
- - Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay). У **`base/kustomization.yaml`** завжди задавай непорожній **`namespace:`**. У **`k8s/base`** у кожному ресурсному YAML має бути явний **`metadata.namespace`**. Поза **base**, якщо не хочеш **`metadata.namespace`** у файлі — підключи його до kustomization (**`resources`** / **`patches`** тощо); інакше додай явний **`metadata.namespace`**.
197
- - Після міграції на нову структуру **видали** застарілі файли та каталоги, які вже замінені (див. **Міграція зі старої структури**).
223
+ - Після змін у k8s YAML: **`npx @nitra/cursor check k8s`** і за наявності правила — **`bun run lint-k8s`**.
224
+ - Оновив **`apiVersion` / `kind`**підправ **перший** рядок **`$schema`** (див. **Визначення схеми YAML**).
225
+ - Дотримуйся **Kustomize** з цього правила; деталі **namespace** / графа ресурсів **check k8s** + підказки в JSDoc скрипта.
198
226
 
199
227
  ## Визначення схеми YAML (канон)
200
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.61",
3
+ "version": "1.8.64",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -6,9 +6,9 @@
6
6
  * (datree за замовчуванням: GitHub Pages `https://datreeio.github.io/CRDs-catalog/…`).
7
7
  *
8
8
  * Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
9
- * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
9
+ * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об’єкт, допускається
10
10
  * порожній **`{}`**). Поле **`imagePullPolicy`** не перевіряється — діють типові правила Kubernetes
11
- * (`:latest` або без тега → **Always**, інші теги → **IfNotPresent**). Якщо серед **`containers`** /
11
+ * (`:latest` або коли тег не вказано → **Always**, інші теги → **IfNotPresent**). Якщо серед **`containers`** /
12
12
  * **`initContainers`** є образ **`hasura/graphql-engine`**, дозволено лише пін **`HASURA_GRAPHQL_ENGINE_IMAGE`**
13
13
  * (див. k8s.mdc).
14
14
  *
@@ -27,6 +27,16 @@
27
27
  * У **`kind: Service`** у **`metadata.annotations`** не повинно бути ключів **`cloud.google.com/neg`**
28
28
  * та **`cloud.google.com/backend-config`** (див. k8s.mdc).
29
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
+ *
30
40
  * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
31
41
  * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
32
42
  *
@@ -296,6 +306,79 @@ function pathsFromKustomizationObject(obj) {
296
306
  return out
297
307
  }
298
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
+
299
382
  /**
300
383
  * Збирає відносні шляхи (posix) до YAML, підключених до Kustomize з будь-якого **`kustomization.yaml`** під `k8s`.
301
384
  * Обходить **`resources`**, **`bases`**, **`components`**, **`crds`**, **`patches[].path`**, **`patchesStrategicMerge`**;
@@ -439,7 +522,6 @@ function k8sYamlBodyForDocumentParse(lines) {
439
522
 
440
523
  /**
441
524
  * Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
442
- *
443
525
  * @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
444
526
  * @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed — не вдалося розпарсити YAML
445
527
  */
@@ -475,7 +557,6 @@ export function classifyBackendConfigManifestPresence(body) {
475
557
 
476
558
  /**
477
559
  * Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли — `fail`.
478
- *
479
560
  * @param {string} root корінь репозиторію
480
561
  * @param {(msg: string) => void} fail реєстрація порушення
481
562
  * @param {(msg: string) => void} pass реєстрація успіху
@@ -530,6 +611,37 @@ function yamlBodyAfterModeline(lines) {
530
611
  return lines.slice(i).join('\n')
531
612
  }
532
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[]} масив успішно розібраних коренів YAML-документів
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
+
533
645
  /**
534
646
  * Перший YAML-документ (до наступного `---` на окремому рядку).
535
647
  * @param {string} body фрагмент YAML
@@ -610,7 +722,7 @@ function scanIngressInYamlDocuments(rel, body, fail) {
610
722
 
611
723
  /**
612
724
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
613
- * @param {unknown} manifest корінь YAML-документа як об'єкт JavaScript
725
+ * @param {unknown} manifest корінь YAML-документа як запис JavaScript
614
726
  * @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
615
727
  */
616
728
  export function deploymentResourcesViolation(manifest) {
@@ -640,7 +752,7 @@ export function deploymentResourcesViolation(manifest) {
640
752
  }
641
753
  const r = cont.resources
642
754
  if (r === null || typeof r !== 'object' || Array.isArray(r)) {
643
- return `контейнер "${label}": resources має бути об'єктом (наприклад порожній об'єкт у YAML: resources: {})`
755
+ return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
644
756
  }
645
757
  }
646
758
  }
@@ -649,7 +761,7 @@ export function deploymentResourcesViolation(manifest) {
649
761
  }
650
762
 
651
763
  /**
652
- * Прибирає digest з посилання на образ (`@sha256:…`) для порівняння тега.
764
+ * Прибирає digest з посилання на образ (`@sha256:…`) для порівняння тегу образу.
653
765
  * @param {string} image значення поля `image`
654
766
  * @returns {string} той самий рядок без суфікса `@…` (digest), з `.trim()`
655
767
  */
@@ -659,7 +771,7 @@ function stripImageDigest(image) {
659
771
  }
660
772
 
661
773
  /**
662
- * Чи рядок `image` вказує на репозиторій **hasura/graphql-engine** (будь-який тег / без тега).
774
+ * Чи рядок `image` вказує на репозиторій **hasura/graphql-engine** (будь-який тег / без вказаного тегу).
663
775
  * @param {string} image значення поля `image`
664
776
  * @returns {boolean} true, якщо шлях образу закінчується на `hasura/graphql-engine` з тегом або без
665
777
  */
@@ -696,7 +808,7 @@ function hasuraGraphqlEngineViolationInContainerList(list, containers) {
696
808
  }
697
809
 
698
810
  /**
699
- * Чи порушує **Deployment** вимогу пінованого образу **hasura/graphql-engine** (k8s.mdc).
811
+ * Чи порушує **Deployment** вимогу щодо зафіксованого образу **hasura/graphql-engine** (k8s.mdc).
700
812
  * @param {unknown} manifest корінь YAML-документа
701
813
  * @returns {string | null} текст порушення або null, якщо не Deployment / образу немає / ок
702
814
  */
@@ -747,6 +859,287 @@ export function serviceForbiddenGcpAnnotationsViolation(manifest) {
747
859
  return `metadata.annotations: прибери заборонені ключі GKE: ${found.join(', ')} (див. k8s.mdc)`
748
860
  }
749
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
+
750
1143
  /**
751
1144
  * Для маніфестів, **підключених** до Kustomize (шлях у `resources` / `patches` / …), **metadata.namespace** не додають.
752
1145
  * @param {unknown} manifest корінь YAML-документа
@@ -816,7 +1209,8 @@ export function isK8sBaseManifestYamlPath(rel, baseLower) {
816
1209
 
817
1210
  /**
818
1211
  * Парсить усі YAML-документи: **metadata.namespace**, **Deployment.resources**, **Hasura image pin**,
819
- * **Service — заборонені GKE-анотації**.
1212
+ * **Service — заборонені GKE-анотації**, **`svc.yaml`** (**`spec.type: ClusterIP`**), **`svc-hl.yaml`**
1213
+ * (**headless**, суфікс **`-hl`** у **`metadata.name`**).
820
1214
  * @param {string} rel відносний шлях
821
1215
  * @param {string} baseLower basename файлу (нижній регістр)
822
1216
  * @param {string} body вміст після modeline
@@ -872,6 +1266,18 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
872
1266
  if (svcGcpV !== null) {
873
1267
  fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
874
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
+ }
875
1281
  }
876
1282
  }
877
1283
  }
@@ -1024,6 +1430,8 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
1024
1430
 
1025
1431
  const kustomizeManaged = kustomizeManagedRel.has(rel)
1026
1432
  validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
1433
+
1434
+ scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
1027
1435
  }
1028
1436
 
1029
1437
  /**
@@ -1111,6 +1519,10 @@ export async function check() {
1111
1519
  await checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel)
1112
1520
  }
1113
1521
 
1522
+ await validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail)
1523
+
1524
+ await validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail)
1525
+
1114
1526
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1115
1527
 
1116
1528
  return exitCode