@nitra/cursor 1.8.19 → 1.8.24

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
@@ -27,9 +27,24 @@
27
27
  | `js-format` | Правила форматування JavaScript ecosystem (oxfmt) |
28
28
  | `npm-module` | Структура репозиторію для npm-модуля (bun mono) |
29
29
  | `text` | Текстові файли: cspell, CI |
30
+ | `k8s` | Kubernetes YAML, Kustomize, kubeconform |
30
31
 
31
32
  Щоб використовувати конкретну версію правил, оновіть залежність `@nitra/cursor` у проєкті (`bun add -d @nitra/cursor@<версія>` тощо). Поле `version` у `.n-cursor.json`, якщо воно лишилось у старих конфігах, **ігнорується**.
32
33
 
34
+ ### Правило `k8s` і Kustomize
35
+
36
+ У цільовому репозиторії з маніфестами під **`**/k8s`** дотримуйтесь **`mdc/k8s.mdc`** з пакету (після синку — `.cursor/rules/n-k8s.mdc`або копія з`node_modules/@nitra/cursor/mdc/k8s.mdc`).
37
+
38
+ Коротко:
39
+
40
+ - **Структура Kustomize:** спільне виноситься в **`base`**; вміст **base** відповідає тому, як має виглядати середовище **dev**; окремої директорії **`dev/`** немає — за dev відповідає **`base`**. У інших середовищах — тонкі **overlays** (часто лише **`kustomization.yaml`** і patches / оверрайди).
41
+ - **Namespace** задається в **`kustomization.yaml`** (`namespace:`), а не через **`metadata.namespace`** у кожному ресурсі; окремі patches лише на зміну **namespace** не потрібні.
42
+ - У **Deployment** для кожного контейнера: **`resources`**, **`imagePullPolicy: Always`** (перевіряє **`npx @nitra/cursor check k8s`**).
43
+ - Рядки в **base**, які змінюються в overlays, позначайте коментарем на рядку (узгоджено в команді), наприклад: `# буде замінено через kustomize`.
44
+ - Після перенесення в **`base`** / overlays **видаляйте** застарілі маніфести та каталоги, які більше не потрібні.
45
+
46
+ Повний текст правил — у **`k8s.mdc`**; programmatic перевірки — у **`npm/scripts/check-k8s.mjs`** (у встановленому пакеті — `scripts/check-k8s.mjs`).
47
+
33
48
  ### v8r і власний каталог схем
34
49
 
35
50
  Скрипт `scripts/run-v8r.mjs` передає в v8r каталог **`schemas/v8r-catalog.json`** пакета автоматично (у репозиторії той самий файл, що й `npm/schemas/v8r-catalog.json` від кореня монорепо). Якщо викликаєш `bunx v8r` напряму, передай `-c`: локально `node_modules/@nitra/cursor/schemas/v8r-catalog.json` або [unpkg](https://unpkg.com/@nitra/cursor/schemas/v8r-catalog.json). JSON Schema конфігурації: [n-cursor.json](https://unpkg.com/@nitra/cursor/schemas/n-cursor.json).
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.13'
3
+ version: '1.18'
4
4
  globs: "**/k8s/**/*.{yaml,yml}"
5
5
  alwaysApply: false
6
6
  ---
@@ -125,9 +125,77 @@ resources: {}
125
125
 
126
126
  Так маніфест явно резервує місце під **`requests` / `limits`** і уникає випадкового пропуску секції. **`check k8s`** перевіряє це для кожного YAML-документа **`Deployment`** у файлах під **`k8s`**.
127
127
 
128
+ У кожному контейнері **`Deployment`** має бути **`imagePullPolicy: Always`** (див. **`check k8s`**).
129
+
130
+ ## Kustomize: структура каталогів (`base` / overlays)
131
+
132
+ Трансформуй дерева **`**/k8s`**, щоб **винести спільне** через [Kustomize](https://kustomize.io/): один канонічний **`base`** і тонкі **overlays** для інших середовищ.
133
+
134
+ ### Джерело правди — середовище dev
135
+
136
+ - За основу бери **все, що відповідає середовищу dev** (як воно має виглядати в кластері для dev).
137
+ - У **такому вигляді** цей набір стає каталогом **`base`**: спільні маніфести без окремої директорії **`dev/`**.
138
+ - Окремої директорії **`dev`** **не повинно існувати**: за середовище **dev** відповідає **`base`** (застосування **`kubectl apply -k …/base`** або збірка з overlay, який посилається на `base`).
139
+
140
+ ### Overlays (не-dev)
141
+
142
+ - У каталозі кожного іншого середовища (наприклад **`ru/`**, **`prod/`**) має бути **мінімум файлів**: типово лише **`kustomization.yaml`** (посилання на `base`, `patches`, `replacements`, `components` тощо) і ресурси чи додаткові YAML, **необхідні лише для цього overlay**.
143
+ - Відмінності від dev вносяться **оверрайдами** (patches, `images`, `replicas`, `configMapGenerator` тощо), а не копіюванням повного дерева з `base`.
144
+
145
+ ### Namespace
146
+
147
+ - У **`base/kustomization.yaml`** задай **`namespace: dev`** (або узгоджене ім’я для dev **namespace**), щоб **усі ресурси base** потрапляли в цей namespace через Kustomize.
148
+
149
+ - **Не додавай** окремі **patches** Kustomize, які лише змінюють **namespace**: **namespace** визначає Kustomize; у overlays додаткові зміни — без дублювання логіки **namespace**.
150
+
151
+ ### Рядки, що змінюються між середовищами
152
+
153
+ - У маніфестах у **`base`** для полів (або значень), які **будуть відрізнятися** в інших середовищах, на **тому самому рядку** додай коментар:
154
+
155
+ ```yaml
156
+ image: my-app:dev-tag # буде замінено через kustomize
157
+ ```
158
+
159
+ Текст коментаря узгодь у команді; важливо, щоб було видно, що значення **навіть у base** може бути замінене overlay.
160
+
161
+ ### Міграція зі старої структури
162
+
163
+ - Після перенесення маніфестів у **`base`** та overlays і перевірки (**`check k8s`**, **`lint-k8s`**) **видали** застарілі файли та директорії, які замінені новою схемою (дубльовані копії, колишні шляхи без Kustomize), щоб у репозиторії не залишалося зайвих або суперечливих маніфестів.
164
+
165
+ ## Ingress → Gateway API (GKE)
166
+
167
+ Якщо в дереві **`k8s`** трапляється маніфест з **`kind: Ingress`**, його потрібно **замінити на Gateway API**, а не залишати Ingress.
168
+
169
+ 1. **HTTPRoute** — окремий файл **`hr.yaml`** (або узгоджене ім’я в команді), **`kind: HTTPRoute`**, `apiVersion` з групи **`gateway.networking.k8s.io`** (див. приклад `$schema` для HTTPRoute у розділі «Визначення схеми YAML»).
170
+ 2. **HealthCheckPolicy (GKE)** — окремий файл **`hc.yaml`**, наприклад:
171
+
172
+ ```yaml
173
+ apiVersion: networking.gke.io/v1
174
+ kind: HealthCheckPolicy
175
+ ```
176
+
177
+ Для `$schema` у першому рядку див. приклад **HealthCheckPolicy** у тому ж розділі (datree CRDs-catalog).
178
+
179
+ 3. **Overlay `ru`:** у **`ru/kustomization.yaml`** (шлях з сегментами **`…/ru/kustomization.yaml`** під **`k8s`**) додай **видалення** ресурсу HealthCheckPolicy для середовища, де політика не потрібна (підстав **реальне ім’я** замість `SERVICE_NAME`):
180
+
181
+ ```yaml
182
+ patches:
183
+ - target:
184
+ kind: HealthCheckPolicy
185
+ patch: |-
186
+ kind: HealthCheckPolicy
187
+ metadata:
188
+ name: SERVICE_NAME
189
+ $patch: delete
190
+ ```
191
+
192
+ За потреби розшир **`target`** (`name`, `namespace`), щоб однозначно вказати об’єкт.
193
+
194
+ **`check k8s`:** заборонено **`kind: Ingress`**; якщо в проєкті є **`kind: HealthCheckPolicy`**, має існувати хоча б один **`ru/kustomization.yaml`** під **`k8s`** із блоком видалення (**`$patch: delete`** для **HealthCheckPolicy**).
195
+
128
196
  ## Перевірка
129
197
 
130
- **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** у файлі наявність **`containers[].resources`** (див. розділ **Deployment: `resources`**). Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
198
+ **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** — **`containers[].resources`**, **`imagePullPolicy: Always`**; заборона **`kind: Ingress`**; наявність **`HealthCheckPolicy`** — вимога до **`ru/kustomization.yaml`** з **`$patch: delete`** (див. **Ingress → Gateway API**); заборона шляхів **`…/k8s/dev/…`**; якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**) — непорожній **`namespace`** у першому документі. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
131
199
 
132
200
  Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
133
201
 
@@ -137,13 +205,20 @@ resources: {}
137
205
 
138
206
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
139
207
  - **`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`).
140
- - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**).
208
+ - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**); у кожного контейнера — **`imagePullPolicy: Always`**.
209
+ - У файлах, ім’я яких **не** `kustomization.yaml` / `kustomization.yml`, у кожному документі — заборона поля **`metadata.namespace`** (**namespace** задається в Kustomize).
210
+ - Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
211
+ - Якщо в будь-якому файлі під **`k8s`** є **`kind: HealthCheckPolicy`**, серед файлів має бути **`ru/kustomization.yaml`** (сегмент шляху **`ru`** перед іменем файлу), а його вміст — patch видалення **HealthCheckPolicy** з **`$patch: delete`** (див. той самий розділ).
212
+ - Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
213
+ - Якщо існує **`k8s/base/kustomization.yaml`** (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`** (типово **`dev`**; див. розділ **Namespace**).
141
214
 
142
215
  ## Коли застосовувати (агентам)
143
216
 
144
217
  - Зміни в k8s YAML — після правок **`check k8s`**.
145
218
  - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
146
- - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**).
219
+ - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**); додай **`imagePullPolicy: Always`** для кожного контейнера.
220
+ - Дотримуйся структури **Kustomize** (`base` = dev, overlays без дублювання `base`, коментарі для рядків, що змінюються в overlay).
221
+ - Після міграції на нову структуру **видали** застарілі файли та каталоги, які вже замінені (див. **Міграція зі старої структури**).
147
222
 
148
223
  ## Визначення схеми YAML (канон)
149
224
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.19",
3
+ "version": "1.8.24",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -7,7 +7,16 @@
7
7
  *
8
8
  * Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
9
9
  * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
10
- * порожній **`{}`**).
10
+ * порожній **`{}`**) та **`imagePullPolicy: Always`**.
11
+ *
12
+ * У файлах **не** `kustomization.yaml` / `kustomization.yml` у документах не має бути **`metadata.namespace`**
13
+ * (namespace лише в Kustomize).
14
+ *
15
+ * **`kind: Ingress`** заборонено (потрібен перехід на Gateway API). Якщо є **`HealthCheckPolicy`**,
16
+ * має існувати **`ru/kustomization.yaml`** з patch видалення цього kind (`$patch: delete`).
17
+ *
18
+ * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; якщо є **`…/k8s/base/kustomization.yaml`**
19
+ * (або **`.yml`**), у першому документі має бути непорожнє поле **`namespace`**.
11
20
  *
12
21
  * Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
13
22
  * **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
@@ -144,6 +153,43 @@ export function pathHasK8sSegment(filePath) {
144
153
  return parts.includes('k8s')
145
154
  }
146
155
 
156
+ /**
157
+ * Чи заборонений шлях з окремою директорією **`dev`** під **`k8s`** (джерело правди — **`base`**).
158
+ * @param {string} rel шлях від кореня репозиторію
159
+ * @returns {boolean} true для `…/k8s/dev/…`
160
+ */
161
+ export function isForbiddenK8sDevPath(rel) {
162
+ const n = rel.replaceAll('\\', '/')
163
+ return n.includes('/k8s/dev/')
164
+ }
165
+
166
+ /**
167
+ * Чи це **`k8s/base/kustomization.yaml`** або **`kustomization.yml`** (перевірка поля **`namespace`**).
168
+ * @param {string} rel шлях від кореня репозиторію
169
+ * @returns {boolean} true, якщо це `…/k8s/base/kustomization.yaml` або `…/k8s/base/kustomization.yml`
170
+ */
171
+ export function isBaseKustomizationPath(rel) {
172
+ const n = rel.replaceAll('\\', '/')
173
+ return /(^|\/)k8s\/base\/kustomization\.yaml$/u.test(n) || /(^|\/)k8s\/base\/kustomization\.yml$/u.test(n)
174
+ }
175
+
176
+ /**
177
+ * Чи коректне поле **`namespace`** у розібраному Kustomization для **`base`**.
178
+ * @param {unknown} obj перший документ YAML
179
+ * @returns {string | null} текст порушення або null, якщо ок
180
+ */
181
+ export function baseKustomizationNamespaceViolation(obj) {
182
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
183
+ return 'у base/kustomization.yaml має бути непорожній namespace (див. k8s.mdc)'
184
+ }
185
+ const rec = /** @type {Record<string, unknown>} */ (obj)
186
+ const ns = rec.namespace
187
+ if (typeof ns === 'string' && ns.trim() !== '') {
188
+ return null
189
+ }
190
+ return 'у base/kustomization.yaml має бути непорожній namespace (наприклад namespace: dev; див. k8s.mdc)'
191
+ }
192
+
147
193
  /**
148
194
  * Збирає всі yaml/yml під деревом від кореня cwd, якщо шлях містить сегмент `k8s`.
149
195
  * @param {string} root корінь репозиторію (cwd)
@@ -157,7 +203,8 @@ async function findK8sYamlFiles(root) {
157
203
  if (!/\.ya?ml$/iu.test(p)) return
158
204
  out.push(p)
159
205
  })
160
- return out.toSorted((a, b) => a.localeCompare(b))
206
+ // eslint-disable-next-line unicorn/no-array-sort -- toSorted потребує lib ES2023 у перевірці типів IDE
207
+ return [...out].sort((a, b) => a.localeCompare(b))
161
208
  }
162
209
 
163
210
  /**
@@ -205,6 +252,103 @@ function extractApiVersionAndKind(doc) {
205
252
  }
206
253
  }
207
254
 
255
+ /**
256
+ * Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім’ям файлу).
257
+ * @param {string} rel шлях від кореня репозиторію
258
+ * @returns {boolean} true, якщо це `…/ru/kustomization.yaml`
259
+ */
260
+ export function isRuKustomizationPath(rel) {
261
+ const norm = rel.replaceAll('\\', '/')
262
+ return /(^|\/)ru\/kustomization\.yaml$/u.test(norm)
263
+ }
264
+
265
+ /**
266
+ * Чи вміст overlay **`ru/kustomization.yaml`** містить Kustomize patch видалення **HealthCheckPolicy**.
267
+ * @param {string} raw повний текст файлу
268
+ * @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
269
+ */
270
+ export function ruKustomizationHasHealthCheckDeletePatch(raw) {
271
+ if (!/\$patch:\s*delete/u.test(raw)) return false
272
+ if (!/kind:\s*HealthCheckPolicy/u.test(raw)) return false
273
+ if (!/metadata:/u.test(raw)) return false
274
+ if (!/name:\s*\S+/u.test(raw)) return false
275
+ return true
276
+ }
277
+
278
+ /**
279
+ * Шукає **Ingress** / **HealthCheckPolicy** у розібраних документах; реєструє порушення для Ingress.
280
+ * @param {string} rel відносний шлях до файлу
281
+ * @param {string} body YAML після modeline
282
+ * @param {(msg: string) => void} fail callback для помилки (Ingress)
283
+ * @param {string[]} healthCheckPolicyFiles накопичувач шляхів, де зустріли HealthCheckPolicy
284
+ * @returns {void}
285
+ */
286
+ function scanIngressAndHealthCheckPolicy(rel, body, fail, healthCheckPolicyFiles) {
287
+ /** @type {import('yaml').Document[]} */
288
+ let docs
289
+ try {
290
+ docs = parseAllDocuments(body)
291
+ } catch {
292
+ return
293
+ }
294
+
295
+ for (const [di, doc] of docs.entries()) {
296
+ if (doc.errors.length === 0) {
297
+ const obj = doc.toJSON()
298
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
299
+ const rec = /** @type {Record<string, unknown>} */ (obj)
300
+ if (rec.kind === 'Ingress') {
301
+ fail(
302
+ `${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml), patch у ru/kustomization.yaml (див. k8s.mdc)`
303
+ )
304
+ } else if (rec.kind === 'HealthCheckPolicy' && !healthCheckPolicyFiles.includes(rel)) {
305
+ healthCheckPolicyFiles.push(rel)
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Якщо у дереві k8s є HealthCheckPolicy, вимагає **ru/kustomization.yaml** з patch видалення.
314
+ * @param {string} root корінь cwd
315
+ * @param {string[]} yamlFiles абсолютні шляхи до yaml під k8s
316
+ * @param {string[]} healthCheckPolicyFiles відносні шляхи з HealthCheckPolicy
317
+ * @param {(msg: string) => void} fail callback для помилки (немає ru або немає patch)
318
+ * @returns {Promise<void>} завершення після перевірки overlay ru
319
+ */
320
+ async function ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyFiles, fail) {
321
+ if (healthCheckPolicyFiles.length === 0) {
322
+ return
323
+ }
324
+
325
+ const ruAbsList = yamlFiles.filter(abs => isRuKustomizationPath(relative(root, abs) || abs))
326
+ if (ruAbsList.length === 0) {
327
+ fail(
328
+ `Знайдено HealthCheckPolicy у ${healthCheckPolicyFiles.join(', ')} — додай ru/kustomization.yaml з patch видалення (див. k8s.mdc)`
329
+ )
330
+ return
331
+ }
332
+
333
+ for (const abs of ruAbsList) {
334
+ let raw
335
+ try {
336
+ raw = await readFile(abs, 'utf8')
337
+ } catch (error) {
338
+ const msg = error instanceof Error ? error.message : String(error)
339
+ fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
340
+ return
341
+ }
342
+ if (ruKustomizationHasHealthCheckDeletePatch(raw)) {
343
+ return
344
+ }
345
+ }
346
+
347
+ fail(
348
+ 'Є HealthCheckPolicy, але жоден ru/kustomization.yaml не містить очікуваного patch видалення (kind: HealthCheckPolicy, metadata.name, $patch: delete) — див. k8s.mdc'
349
+ )
350
+ }
351
+
208
352
  /**
209
353
  * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
210
354
  * @param {unknown} manifest корінь YAML-документа як об'єкт JavaScript
@@ -246,32 +390,104 @@ export function deploymentResourcesViolation(manifest) {
246
390
  }
247
391
 
248
392
  /**
249
- * Парсить усі YAML-документи з тіла файлу й реєструє порушення **`Deployment.resources`**.
250
- * @param {string} rel відносний шлях (для повідомлень)
393
+ * Чи контейнери **Deployment** мають **`imagePullPolicy: Always`** (k8s.mdc).
394
+ * @param {unknown} manifest корінь YAML-документа
395
+ * @returns {string | null} текст порушення або null, якщо не Deployment / ок
396
+ */
397
+ export function deploymentImagePullPolicyViolation(manifest) {
398
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
399
+ return null
400
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
401
+ if (rec.kind !== 'Deployment') return null
402
+ const spec = rec.spec
403
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
404
+ const template = /** @type {Record<string, unknown>} */ (spec).template
405
+ if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template))
406
+ return null
407
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
408
+ if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
409
+ const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
410
+ if (!Array.isArray(containers)) return null
411
+
412
+ for (const [i, c] of containers.entries()) {
413
+ const label =
414
+ typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
415
+ ? c.name
416
+ : `#${i + 1}`
417
+ if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
418
+ const cont = /** @type {Record<string, unknown>} */ (c)
419
+ if (cont.imagePullPolicy !== 'Always') {
420
+ return `контейнер "${label}": imagePullPolicy має бути Always (див. k8s.mdc)`
421
+ }
422
+ }
423
+ }
424
+
425
+ return null
426
+ }
427
+
428
+ /**
429
+ * У маніфестах ресурсів не має бути **metadata.namespace** — лише у **kustomization** (k8s.mdc).
430
+ * @param {unknown} manifest корінь YAML-документа
431
+ * @returns {string | null} текст порушення або null, якщо поля немає
432
+ */
433
+ export function metadataNamespaceForbiddenViolation(manifest) {
434
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
435
+ return null
436
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
437
+ const meta = rec.metadata
438
+ if (meta !== null && typeof meta === 'object' && !Array.isArray(meta) && 'namespace' in meta) {
439
+ return 'metadata.namespace заборонено — задай namespace у kustomization.yaml (поле namespace) (див. k8s.mdc)'
440
+ }
441
+ return null
442
+ }
443
+
444
+ /**
445
+ * Чи ім’я файлу — kustomization (дозволяє не застосовувати перевірку metadata.namespace до вмісту).
446
+ * @param {string} baseLower basename у нижньому регістрі
447
+ * @returns {boolean} true для `kustomization.yaml` / `kustomization.yml`
448
+ */
449
+ function isKustomizationFileName(baseLower) {
450
+ return baseLower === 'kustomization.yaml' || baseLower === 'kustomization.yml'
451
+ }
452
+
453
+ /**
454
+ * Парсить усі YAML-документи: **metadata.namespace**, **Deployment.resources**, **imagePullPolicy**.
455
+ * @param {string} rel відносний шлях
456
+ * @param {string} baseLower basename файлу (нижній регістр)
251
457
  * @param {string} body вміст після modeline
252
458
  * @param {(msg: string) => void} fail реєстрація помилки
253
459
  */
254
- function validateDeploymentResourcesInK8sYaml(rel, body, fail) {
460
+ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail) {
255
461
  /** @type {import('yaml').Document[]} */
256
462
  let docs
257
463
  try {
258
464
  docs = parseAllDocuments(body)
259
465
  } catch (error) {
260
466
  const msg = error instanceof Error ? error.message : String(error)
261
- fail(
262
- `${rel}: не вдалося розібрати YAML для перевірки Deployment.spec.template.spec.containers[].resources (${msg})`
263
- )
467
+ fail(`${rel}: не вдалося розібрати YAML для перевірок маніфестів (${msg})`)
264
468
  return
265
469
  }
266
470
 
471
+ const skipMetaNs = isKustomizationFileName(baseLower)
472
+
267
473
  for (const [di, doc] of docs.entries()) {
268
474
  if (doc.errors.length > 0) {
269
475
  fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
270
476
  } else {
271
477
  const obj = doc.toJSON()
272
- const v = deploymentResourcesViolation(obj)
273
- if (v !== null) {
274
- fail(`${rel}: Deployment (документ ${di + 1}): ${v}`)
478
+ if (!skipMetaNs) {
479
+ const ns = metadataNamespaceForbiddenViolation(obj)
480
+ if (ns !== null) {
481
+ fail(`${rel}: документ ${di + 1}: ${ns}`)
482
+ }
483
+ }
484
+ const resV = deploymentResourcesViolation(obj)
485
+ if (resV !== null) {
486
+ fail(`${rel}: Deployment (документ ${di + 1}): ${resV}`)
487
+ }
488
+ const pullV = deploymentImagePullPolicyViolation(obj)
489
+ if (pullV !== null) {
490
+ fail(`${rel}: Deployment (документ ${di + 1}): ${pullV}`)
275
491
  }
276
492
  }
277
493
  }
@@ -358,9 +574,10 @@ function countSchemaModelines(lines) {
358
574
  * @param {string} root корінь репозиторію
359
575
  * @param {(msg: string) => void} fail реєстрація помилки
360
576
  * @param {(msg: string) => void} pass реєстрація успіху
577
+ * @param {string[]} healthCheckPolicyFiles накопичувач файлів із kind: HealthCheckPolicy
361
578
  * @returns {Promise<void>}
362
579
  */
363
- async function checkK8sYamlFile(abs, root, fail, pass) {
580
+ async function checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles) {
364
581
  const rel = relative(root, abs) || abs
365
582
  const base = basename(abs)
366
583
  const baseLower = base.toLowerCase()
@@ -398,6 +615,8 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
398
615
 
399
616
  const body = yamlBodyAfterModeline(lines)
400
617
 
618
+ scanIngressAndHealthCheckPolicy(rel, body, fail, healthCheckPolicyFiles)
619
+
401
620
  if (schemaUrl.startsWith('file:')) {
402
621
  pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
403
622
  } else if (/^https:/iu.test(schemaUrl)) {
@@ -420,7 +639,60 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
420
639
  return
421
640
  }
422
641
 
423
- validateDeploymentResourcesInK8sYaml(rel, body, fail)
642
+ validateK8sYamlPolicyDocuments(rel, baseLower, body, fail)
643
+ }
644
+
645
+ /**
646
+ * Реєструє порушення для шляхів виду **`…/k8s/dev/…`** (окремої директорії **dev** не має бути).
647
+ * @param {string[]} yamlFiles абсолютні шляхи
648
+ * @param {string} root корінь репозиторію
649
+ * @param {(msg: string) => void} fail callback для реєстрації порушення
650
+ * @returns {void}
651
+ */
652
+ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
653
+ for (const abs of yamlFiles) {
654
+ const rel = relative(root, abs).replaceAll('\\', '/')
655
+ if (isForbiddenK8sDevPath(rel)) {
656
+ fail(`${rel}: заборонена директорія k8s/dev/ — середовище dev відповідає base (див. k8s.mdc)`)
657
+ }
658
+ }
659
+ }
660
+
661
+ /**
662
+ * Якщо є **`k8s/base/kustomization.yaml`**, у ньому має бути непорожній **`namespace`**.
663
+ * @param {string} root корінь репозиторію
664
+ * @param {string[]} yamlFiles абсолютні шляхи
665
+ * @param {(msg: string) => void} fail callback для реєстрації порушення
666
+ * @returns {Promise<void>}
667
+ */
668
+ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
669
+ for (const abs of yamlFiles) {
670
+ const rel = relative(root, abs).replaceAll('\\', '/')
671
+ if (isBaseKustomizationPath(rel)) {
672
+ try {
673
+ const raw = await readFile(abs, 'utf8')
674
+ const lines = toLines(raw)
675
+ const body = yamlBodyAfterModeline(lines)
676
+ /** @type {import('yaml').Document[] | undefined} */
677
+ let docs
678
+ try {
679
+ docs = parseAllDocuments(body)
680
+ } catch {
681
+ fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
682
+ }
683
+ if (docs !== undefined) {
684
+ const first = docs[0]?.toJSON()
685
+ const v = baseKustomizationNamespaceViolation(first)
686
+ if (v) {
687
+ fail(`${rel}: ${v}`)
688
+ }
689
+ }
690
+ } catch (error) {
691
+ const msg = error instanceof Error ? error.message : String(error)
692
+ fail(`${rel}: не вдалося прочитати (${msg})`)
693
+ }
694
+ }
695
+ }
424
696
  }
425
697
 
426
698
  /**
@@ -444,9 +716,18 @@ export async function check() {
444
716
 
445
717
  pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
446
718
 
719
+ assertNoForbiddenK8sDevPaths(yamlFiles, root, fail)
720
+
721
+ /** @type {string[]} */
722
+ const healthCheckPolicyFiles = []
723
+
447
724
  for (const abs of yamlFiles) {
448
- await checkK8sYamlFile(abs, root, fail, pass)
725
+ await checkK8sYamlFile(abs, root, fail, pass, healthCheckPolicyFiles)
449
726
  }
450
727
 
728
+ await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyFiles, fail)
729
+
730
+ await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
731
+
451
732
  return exitCode
452
733
  }