@nitra/cursor 1.8.116 → 1.8.117

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
@@ -298,13 +298,43 @@ data:
298
298
 
299
299
  **Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
300
300
 
301
- - HPA: `minReplicas === 1`.
302
- - PDB: `minAvailable === 0`.
301
+ - HPA: `minReplicas` рівно **1**, `maxReplicas` — рівно **1** (у деві не масштабуємо).
302
+ - PDB: `minAvailable` рівно **0**.
303
303
 
304
304
  **Прод-середовища** — усе інше (будь-який overlay без суфікса `-qa`):
305
305
 
306
- - HPA: `minReplicas >= 2`, `maxReplicas >= 2`.
307
- - PDB: `minAvailable >= 1`.
306
+ - HPA: `minReplicas` мінімум **2**, `maxReplicas` мінімум **2**.
307
+ - PDB: `minAvailable` мінімум **1**.
308
+
309
+ ### Прод-оверрайди у `kustomization.yaml`
310
+
311
+ Оскільки `base/` (і dev-like середовища) тримають dev-значення (`1`/`1`/`0`), у **кожному** прод-оверлеї `kustomization.yaml` у `patches[]` **обов'язково** має бути перевизначення:
312
+
313
+ - для `HorizontalPodAutoscaler`: `spec.minReplicas` **і** `spec.maxReplicas` (щоб у проді вийшло ≥2).
314
+ - для `PodDisruptionBudget`: `spec.minAvailable` (щоб у проді вийшло ≥1).
315
+
316
+ Формат patch — JSON6902 або Strategic Merge; `check k8s` перевіряє **наявність** оверрайду відповідного поля. Конкретне значення має задовольняти прод-мінімуми — це видно у вмісті patch і остаточно матеріалізується під час збірки Kustomize.
317
+
318
+ ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
319
+ patches:
320
+ - target:
321
+ kind: HorizontalPodAutoscaler
322
+ name: backend-api
323
+ patch: |-
324
+ - op: replace
325
+ path: /spec/minReplicas
326
+ value: 3
327
+ - op: replace
328
+ path: /spec/maxReplicas
329
+ value: 10
330
+ - target:
331
+ kind: PodDisruptionBudget
332
+ name: backend-api
333
+ patch: |-
334
+ - op: replace
335
+ path: /spec/minAvailable
336
+ value: 1
337
+ ```
308
338
 
309
339
  ### Приклади
310
340
 
@@ -319,8 +349,8 @@ spec:
319
349
  apiVersion: apps/v1
320
350
  kind: Deployment
321
351
  name: backend-api
322
- minReplicas: 1 # прод: >= 2
323
- maxReplicas: 10
352
+ minReplicas: 1 # dev-like; прод-оверлеї перевизначають на ≥ 2
353
+ maxReplicas: 1 # dev-like; прод-оверлеї перевизначають на ≥ 2
324
354
  metrics:
325
355
  - type: Resource
326
356
  resource:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.116",
3
+ "version": "1.8.117",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -68,10 +68,17 @@
68
68
  * обов'язкові **`hpa.yaml`** (`autoscaling/v2`, `HorizontalPodAutoscaler`, `scaleTargetRef.name` = ім'я Deployment)
69
69
  * і **`pdb.yaml`** (`policy/v1`, `PodDisruptionBudget`, `selector.matchLabels.app` = мітка `app` Deployment).
70
70
  * Env-залежні межі за сегментом після `/k8s/`: **dev-like** (`base`, `dev`, `*-qa`) — `minReplicas === 1`,
71
- * `minAvailable === 0`; **прод** (решта) — `minReplicas >= 2`, `maxReplicas >= 2`, `minAvailable >= 1`.
72
- * Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
71
+ * `maxReplicas === 1`, `minAvailable === 0`; **прод** (решта) — `minReplicas >= 2`, `maxReplicas >= 2`,
72
+ * `minAvailable >= 1`. Сам Deployment має мати у `spec.template.spec.topologySpreadConstraints` запис
73
73
  * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
74
74
  * `labelSelector.matchLabels.app` рівне `spec.selector.matchLabels.app` Deployment.
75
+ *
76
+ * **Прод-оверрайди в kustomization.yaml:** для прод-оверлеїв (не dev-like) `kustomization.yaml` у своїх
77
+ * inline `patches[]` повинен змінювати `/spec/minReplicas` і `/spec/maxReplicas` для
78
+ * **HorizontalPodAutoscaler**, і `/spec/minAvailable` для **PodDisruptionBudget** — щоб прод-мінімуми
79
+ * з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
80
+ * наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
81
+ * яке буде оцінено під час збірки Kustomize).
75
82
  */
76
83
  import { existsSync } from 'node:fs'
77
84
  import { readFile, readdir, stat, unlink } from 'node:fs/promises'
@@ -3521,7 +3528,7 @@ export function k8sEnvSegmentFromRelPath(relPath) {
3521
3528
  export function isDevLikeK8sEnvSegment(segment) {
3522
3529
  if (typeof segment !== 'string' || segment === '') return false
3523
3530
  if (segment === 'base' || segment === 'dev') return true
3524
- return /-qa$/u.test(segment)
3531
+ return segment.endsWith('-qa')
3525
3532
  }
3526
3533
 
3527
3534
  /**
@@ -3606,6 +3613,7 @@ export function hpaManifestViolations(manifest, expectedDeployName, isDevLike) {
3606
3613
  }
3607
3614
  if (isDevLike) {
3608
3615
  if (minR !== null && minR !== 1) errs.push(`spec.minReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${minR})`)
3616
+ if (maxR !== null && maxR !== 1) errs.push(`spec.maxReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${maxR})`)
3609
3617
  } else {
3610
3618
  if (minR !== null && minR < 2) errs.push(`spec.minReplicas для прод середовища має бути мінімум 2 (зараз: ${minR})`)
3611
3619
  if (maxR !== null && maxR < 2) errs.push(`spec.maxReplicas для прод середовища має бути мінімум 2 (зараз: ${maxR})`)
@@ -3770,6 +3778,179 @@ async function readDocsByKindInDir(dirPath, kind, filenameFilter) {
3770
3778
  return out
3771
3779
  }
3772
3780
 
3781
+ /**
3782
+ * Збирає шляхи **JSON Pointer**, які змінює один inline `patch` у **`patches[]`** kustomization.yaml.
3783
+ * Підтримка двох форматів:
3784
+ * — **JSON6902** (масив операцій): беремо `path` кожної операції (через `collectJson6902OperationsFromPatchText`).
3785
+ * — **Strategic Merge** (YAML-обʼєкт): плоскі шляхи до всіх листових полів (наприклад
3786
+ * `spec.minReplicas: 2` → `/spec/minReplicas`). Проміжні обʼєкти не вважаються «зміненими» — лише листки.
3787
+ * @param {string} patchText вміст поля `patch`
3788
+ * @returns {Set<string>} шляхи JSON Pointer (наприклад `/spec/minReplicas`)
3789
+ */
3790
+ export function kustomizePatchModifiedPaths(patchText) {
3791
+ /** @type {Set<string>} */
3792
+ const out = new Set()
3793
+ const t = typeof patchText === 'string' ? patchText.trim() : ''
3794
+ if (t === '') return out
3795
+ const ops = collectJson6902OperationsFromPatchText(patchText)
3796
+ if (ops.length > 0) {
3797
+ for (const { path } of ops) {
3798
+ if (path) out.add(path)
3799
+ }
3800
+ return out
3801
+ }
3802
+ let parsed
3803
+ try {
3804
+ for (const d of parseAllDocuments(t)) {
3805
+ if (d.errors.length === 0) {
3806
+ parsed = d.toJSON()
3807
+ break
3808
+ }
3809
+ }
3810
+ } catch {
3811
+ return out
3812
+ }
3813
+ if (parsed === null || parsed === undefined || typeof parsed !== 'object' || Array.isArray(parsed)) return out
3814
+ /**
3815
+ * Рекурсивний обхід: шлях додаємо лише для листків (скаляр / масив).
3816
+ * @param {Record<string, unknown>} obj
3817
+ * @param {string} prefix
3818
+ */
3819
+ const walk = (obj, prefix) => {
3820
+ for (const [k, v] of Object.entries(obj)) {
3821
+ const p = `${prefix}/${k}`
3822
+ if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
3823
+ walk(/** @type {Record<string, unknown>} */ (v), p)
3824
+ } else {
3825
+ out.add(p)
3826
+ }
3827
+ }
3828
+ }
3829
+ walk(/** @type {Record<string, unknown>} */ (parsed), '')
3830
+ return out
3831
+ }
3832
+
3833
+ /**
3834
+ * Читає `kind` з inline **`patch`** у форматі Strategic Merge (для випадків, коли **`target.kind`** не заданий).
3835
+ * @param {string} patchText вміст поля `patch`
3836
+ * @returns {string | null} значення `kind` першого документа або null
3837
+ */
3838
+ function strategicMergePatchKind(patchText) {
3839
+ const t = typeof patchText === 'string' ? patchText.trim() : ''
3840
+ if (t === '') return null
3841
+ try {
3842
+ for (const d of parseAllDocuments(t)) {
3843
+ if (d.errors.length === 0) {
3844
+ const obj = d.toJSON()
3845
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3846
+ const k = /** @type {Record<string, unknown>} */ (obj).kind
3847
+ if (typeof k === 'string' && k !== '') return k
3848
+ }
3849
+ }
3850
+ }
3851
+ } catch {
3852
+ return null
3853
+ }
3854
+ return null
3855
+ }
3856
+
3857
+ /**
3858
+ * Збирає шляхи, змінені всіма inline `patches[]` у kustomization, згрупованими за `kind` цілі.
3859
+ * `kind` визначається з `target.kind` (канон) або, якщо відсутній — з `kind:` у тілі Strategic Merge patch.
3860
+ * @param {Record<string, unknown>} kust об'єкт kustomization.yaml
3861
+ * @returns {Map<string, Set<string>>} `kind` → шляхи JSON Pointer, які overrides змінюють
3862
+ */
3863
+ export function kustomizationPatchPathsByTargetKind(kust) {
3864
+ /** @type {Map<string, Set<string>>} */
3865
+ const byKind = new Map()
3866
+ const patches = kust.patches
3867
+ if (!Array.isArray(patches)) return byKind
3868
+ for (const p of patches) {
3869
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
3870
+ const pr = /** @type {Record<string, unknown>} */ (p)
3871
+ if (typeof pr.patch !== 'string') continue
3872
+ /** @type {string | null} */
3873
+ let kind = null
3874
+ const target = pr.target
3875
+ if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
3876
+ const tk = /** @type {Record<string, unknown>} */ (target).kind
3877
+ if (typeof tk === 'string' && tk !== '') kind = tk
3878
+ }
3879
+ if (kind === null) kind = strategicMergePatchKind(pr.patch)
3880
+ if (kind === null) continue
3881
+ const paths = kustomizePatchModifiedPaths(pr.patch)
3882
+ if (!byKind.has(kind)) byKind.set(kind, new Set())
3883
+ const set = byKind.get(kind)
3884
+ for (const x of paths) set.add(x)
3885
+ }
3886
+ return byKind
3887
+ }
3888
+
3889
+ /**
3890
+ * Для прод kustomization.yaml вимагає патчі, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
3891
+ * на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**. Не застосовується
3892
+ * до dev-like (base / dev / *-qa) — там ці значення беруть з base (див. k8s.mdc).
3893
+ * @param {string} root корінь репозиторію
3894
+ * @param {string[]} yamlFilesAbs yaml під k8s
3895
+ * @param {(msg: string) => void} fail callback при помилці
3896
+ * @param {(msg: string) => void} passFn callback при успіху
3897
+ */
3898
+ async function validateProdKustomizationOverrides(root, yamlFilesAbs, fail, passFn) {
3899
+ const kustFiles = yamlFilesAbs.filter(abs => basename(abs) === 'kustomization.yaml')
3900
+ for (const kustAbs of kustFiles) {
3901
+ const rel = relative(root, kustAbs).replaceAll('\\', '/')
3902
+ const segment = k8sEnvSegmentFromRelPath(rel)
3903
+ if (segment === null || isDevLikeK8sEnvSegment(segment)) continue
3904
+ let raw
3905
+ try {
3906
+ raw = await readFile(kustAbs, 'utf8')
3907
+ } catch {
3908
+ continue
3909
+ }
3910
+ /** @type {Record<string, unknown> | undefined} */
3911
+ let kust
3912
+ try {
3913
+ for (const doc of parseAllDocuments(raw)) {
3914
+ if (doc.errors.length === 0) {
3915
+ const obj = doc.toJSON()
3916
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3917
+ kust = /** @type {Record<string, unknown>} */ (obj)
3918
+ break
3919
+ }
3920
+ }
3921
+ }
3922
+ } catch {
3923
+ continue
3924
+ }
3925
+ if (kust === undefined) continue
3926
+ const byKind = kustomizationPatchPathsByTargetKind(kust)
3927
+ const hpaPaths = byKind.get('HorizontalPodAutoscaler') ?? new Set()
3928
+ const pdbPaths = byKind.get('PodDisruptionBudget') ?? new Set()
3929
+ let ok = true
3930
+ if (!hpaPaths.has('/spec/minReplicas')) {
3931
+ fail(
3932
+ `${rel}: прод-оверлей має перевизначати spec.minReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
3933
+ )
3934
+ ok = false
3935
+ }
3936
+ if (!hpaPaths.has('/spec/maxReplicas')) {
3937
+ fail(
3938
+ `${rel}: прод-оверлей має перевизначати spec.maxReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
3939
+ )
3940
+ ok = false
3941
+ }
3942
+ if (!pdbPaths.has('/spec/minAvailable')) {
3943
+ fail(
3944
+ `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
3945
+ )
3946
+ ok = false
3947
+ }
3948
+ if (ok) {
3949
+ passFn(`${rel}: прод-оверрайди HPA minReplicas/maxReplicas і PDB minAvailable присутні (k8s.mdc)`)
3950
+ }
3951
+ }
3952
+ }
3953
+
3773
3954
  /**
3774
3955
  * Для кожного **Deployment** під `k8s/` перевіряє: у тому ж каталозі повинні бути
3775
3956
  * `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`), а сам Deployment
@@ -3929,5 +4110,7 @@ export async function check() {
3929
4110
 
3930
4111
  await validateDeploymentHpaPdbAndTopology(root, yamlFiles, fail, pass)
3931
4112
 
4113
+ await validateProdKustomizationOverrides(root, yamlFiles, fail, pass)
4114
+
3932
4115
  return reporter.getExitCode()
3933
4116
  }