@nitra/cursor 1.8.118 → 1.8.120

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.
@@ -73,7 +73,7 @@
73
73
  * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
74
74
  * `labelSelector.matchLabels.app` рівне `spec.selector.matchLabels.app` Deployment.
75
75
  *
76
- * **Прод-оверрайди в kustomization.yaml:** для прод-оверлеїв (не dev-like) `kustomization.yaml` у своїх
76
+ * **Прод-оверрайди в kustomization.yaml:** для прод overlays (не dev-like) `kustomization.yaml` у своїх
77
77
  * inline `patches[]` повинен змінювати `/spec/minReplicas` і `/spec/maxReplicas` для
78
78
  * **HorizontalPodAutoscaler**, і `/spec/minAvailable` для **PodDisruptionBudget** — щоб прод-мінімуми
79
79
  * з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
@@ -128,6 +128,12 @@ const DATREE_CRD_RAW_REF = 'main'
128
128
 
129
129
  const DATREE_CRD_RAW_BASE = `https://raw.githubusercontent.com/datreeio/CRDs-catalog/${DATREE_CRD_RAW_REF}/`
130
130
 
131
+ /** Regex: витягує сегмент каталогу після `/k8s/` у POSIX-шляху. */
132
+ const K8S_ENV_SEGMENT_RE = /(?:^|\/)k8s\/([^/]+)(?:\/|$)/u
133
+
134
+ /** Regex: чи рядок є цілим числом (можливо від'ємним). */
135
+ const INTEGER_STRING_RE = /^-?\d+$/u
136
+
131
137
  /** У ключі `Map` означає «будь-який / відсутній `type`» (наприклад CRD без кореневого `type:`). */
132
138
  const K8S_EXPLICIT_SCHEMA_TYPE_ANY = '*'
133
139
 
@@ -2092,43 +2098,180 @@ export function hasuraConfigMapRemoteSchemaPermissionsViolation(manifest) {
2092
2098
  const K8S_YAML_EXT_RE = /\.ya?ml$/iu
2093
2099
 
2094
2100
  /**
2095
- * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-pino.mdc).
2096
- * @param {string} dirPath абсолютний шлях до каталогу
2097
- * @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
2101
+ * Безпечно читає файл і повертає вміст або `undefined` при помилці.
2102
+ * @param {string} filePath абсолютний шлях
2103
+ * @returns {Promise<string | undefined>} вміст файлу або undefined
2098
2104
  */
2099
- export async function findDeploymentDocInDir(dirPath) {
2100
- let entries
2105
+ async function tryReadFileUtf8(filePath) {
2101
2106
  try {
2102
- entries = await readdir(dirPath)
2107
+ return await readFile(filePath, 'utf8')
2103
2108
  } catch {
2104
- return null
2109
+ return
2105
2110
  }
2106
- for (const entry of entries) {
2107
- if (!K8S_YAML_EXT_RE.test(entry)) continue
2108
- let raw
2109
- try {
2110
- raw = await readFile(join(dirPath, entry), 'utf8')
2111
- } catch {
2112
- continue
2113
- }
2114
- let docs
2115
- try {
2116
- docs = parseAllDocuments(raw)
2117
- } catch {
2118
- continue
2111
+ }
2112
+
2113
+ /**
2114
+ * Безпечно парсить YAML і повертає масив документів або `undefined` при помилці.
2115
+ * @param {string} raw вміст YAML-файлу
2116
+ * @returns {import('yaml').Document.Parsed[] | undefined} документи або undefined
2117
+ */
2118
+ function tryParseAllYamlDocs(raw) {
2119
+ try {
2120
+ return parseAllDocuments(raw)
2121
+ } catch {
2122
+ return
2123
+ }
2124
+ }
2125
+
2126
+ /**
2127
+ * Шукає перший документ із заданим `kind` серед YAML-документів.
2128
+ * @param {import('yaml').Document.Parsed[]} docs масив документів (результат парсингу)
2129
+ * @param {string} kind очікуваний `kind`
2130
+ * @returns {Record<string, unknown> | null} знайдений об'єкт або null
2131
+ */
2132
+ function findFirstDocByKind(docs, kind) {
2133
+ for (const doc of docs) {
2134
+ if (doc.errors.length === 0) {
2135
+ const obj = doc.toJSON()
2136
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
2137
+ const rec = /** @type {Record<string, unknown>} */ (obj)
2138
+ if (rec.kind === kind) return rec
2139
+ }
2119
2140
  }
2120
- for (const doc of docs) {
2121
- if (doc.errors.length > 0) continue
2141
+ }
2142
+ return null
2143
+ }
2144
+
2145
+ /**
2146
+ * Збирає всі документи із заданим `kind` серед YAML-документів.
2147
+ * @param {import('yaml').Document.Parsed[]} docs масив документів (результат парсингу)
2148
+ * @param {string} kind очікуваний `kind`
2149
+ * @returns {Record<string, unknown>[]} знайдені об'єкти
2150
+ */
2151
+ function collectDocsByKind(docs, kind) {
2152
+ /** @type {Record<string, unknown>[]} */
2153
+ const out = []
2154
+ for (const doc of docs) {
2155
+ if (doc.errors.length === 0) {
2122
2156
  const obj = doc.toJSON()
2123
2157
  if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
2124
2158
  const rec = /** @type {Record<string, unknown>} */ (obj)
2125
- if (rec.kind === 'Deployment') return rec
2159
+ if (rec.kind === kind) out.push(rec)
2126
2160
  }
2127
2161
  }
2128
2162
  }
2163
+ return out
2164
+ }
2165
+
2166
+ /**
2167
+ * Безпечно читає каталог і повертає масив імен або порожній масив при помилці.
2168
+ * @param {string} dirPath абсолютний шлях до каталогу
2169
+ * @returns {Promise<string[]>} імена файлів/директорій або порожній масив
2170
+ */
2171
+ async function tryReaddir(dirPath) {
2172
+ try {
2173
+ return await readdir(dirPath)
2174
+ } catch {
2175
+ return []
2176
+ }
2177
+ }
2178
+
2179
+ /**
2180
+ * Читає YAML-файл і шукає перший документ із заданим `kind`.
2181
+ * @param {string} filePath абсолютний шлях до YAML-файлу
2182
+ * @param {string} kind очікуваний `kind`
2183
+ * @returns {Promise<Record<string, unknown> | null>} знайдений об'єкт або null
2184
+ */
2185
+ async function readFirstDocByKindFromFile(filePath, kind) {
2186
+ const raw = await tryReadFileUtf8(filePath)
2187
+ if (raw === undefined) return null
2188
+ const docs = tryParseAllYamlDocs(raw)
2189
+ if (docs === undefined) return null
2190
+ return findFirstDocByKind(docs, kind)
2191
+ }
2192
+
2193
+ /**
2194
+ * Знаходить перший документ **Deployment** серед YAML-файлів каталогу (для перевірки імені ConfigMap, js-pino.mdc).
2195
+ * @param {string} dirPath абсолютний шлях до каталогу
2196
+ * @returns {Promise<Record<string, unknown> | null>} об'єкт Deployment або null
2197
+ */
2198
+ export async function findDeploymentDocInDir(dirPath) {
2199
+ const entries = await tryReaddir(dirPath)
2200
+ for (const entry of entries) {
2201
+ if (K8S_YAML_EXT_RE.test(entry)) {
2202
+ const found = await readFirstDocByKindFromFile(join(dirPath, entry), 'Deployment')
2203
+ if (found !== null) return found
2204
+ }
2205
+ }
2129
2206
  return null
2130
2207
  }
2131
2208
 
2209
+ /**
2210
+ * Безпечно отримує вкладений об'єкт за ключем (повертає `null`, якщо не об'єкт).
2211
+ * @param {Record<string, unknown>} parent батьківський об'єкт
2212
+ * @param {string} key ключ
2213
+ * @returns {Record<string, unknown> | null} вкладений об'єкт або null
2214
+ */
2215
+ function getNestedObject(parent, key) {
2216
+ const v = parent[key]
2217
+ if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) return null
2218
+ return /** @type {Record<string, unknown>} */ (v)
2219
+ }
2220
+
2221
+ /**
2222
+ * Витягує **podSpec** (`spec.template.spec`) з об'єкта Deployment.
2223
+ * @param {Record<string, unknown>} deployment об'єкт Deployment
2224
+ * @returns {Record<string, unknown> | null} podSpec або null
2225
+ */
2226
+ function extractPodSpec(deployment) {
2227
+ const spec = getNestedObject(deployment, 'spec')
2228
+ if (spec === null) return null
2229
+ const template = getNestedObject(spec, 'template')
2230
+ if (template === null) return null
2231
+ return getNestedObject(template, 'spec')
2232
+ }
2233
+
2234
+ /**
2235
+ * Збирає імена ConfigMap з `envFrom[*].configMapRef.name` одного контейнера.
2236
+ * @param {unknown} container елемент масиву containers
2237
+ * @param {Set<string>} names набір, куди додаються імена
2238
+ */
2239
+ function collectConfigMapRefsFromContainer(container, names) {
2240
+ if (container === null || typeof container !== 'object' || Array.isArray(container)) return
2241
+ const envFrom = /** @type {Record<string, unknown>} */ (container).envFrom
2242
+ const items = Array.isArray(envFrom) ? /** @type {unknown[]} */ (envFrom) : []
2243
+ for (const ef of items) {
2244
+ if (ef === null || typeof ef !== 'object' || Array.isArray(ef)) {
2245
+ /* пропускаємо скаляри та масиви */
2246
+ } else {
2247
+ const cmr = getNestedObject(/** @type {Record<string, unknown>} */ (ef), 'configMapRef')
2248
+ if (cmr !== null) {
2249
+ const n = cmr.name
2250
+ if (typeof n === 'string' && n.trim() !== '') names.add(n)
2251
+ }
2252
+ }
2253
+ }
2254
+ }
2255
+
2256
+ /**
2257
+ * Збирає імена ConfigMap з `volumes[*].configMap.name`.
2258
+ * @param {unknown[]} volumes масив volumes
2259
+ * @param {Set<string>} names набір, куди додаються імена
2260
+ */
2261
+ function collectConfigMapRefsFromVolumes(volumes, names) {
2262
+ for (const v of volumes) {
2263
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
2264
+ /* пропускаємо скаляри та масиви */
2265
+ } else {
2266
+ const cm = getNestedObject(/** @type {Record<string, unknown>} */ (v), 'configMap')
2267
+ if (cm !== null) {
2268
+ const n = cm.name
2269
+ if (typeof n === 'string' && n.trim() !== '') names.add(n)
2270
+ }
2271
+ }
2272
+ }
2273
+ }
2274
+
2132
2275
  /**
2133
2276
  * Збирає унікальні імена **ConfigMap**, на які посилається **Deployment**
2134
2277
  * через `spec.template.spec.containers[*].envFrom[*].configMapRef.name`
@@ -2139,35 +2282,14 @@ export async function findDeploymentDocInDir(dirPath) {
2139
2282
  export function collectDeploymentConfigMapRefs(deployment) {
2140
2283
  /** @type {Set<string>} */
2141
2284
  const names = new Set()
2142
- const spec = deployment.spec
2143
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return names
2144
- const template = /** @type {Record<string, unknown>} */ (spec).template
2145
- if (template === null || typeof template !== 'object' || Array.isArray(template)) return names
2146
- const podSpec = /** @type {Record<string, unknown>} */ (template).spec
2147
- if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) return names
2148
- const ps = /** @type {Record<string, unknown>} */ (podSpec)
2149
- for (const c of Array.isArray(ps.containers) ? /** @type {unknown[]} */ (ps.containers) : []) {
2150
- if (c === null || typeof c !== 'object' || Array.isArray(c)) continue
2151
- const envFrom = /** @type {Record<string, unknown>} */ (c).envFrom
2152
- for (const ef of Array.isArray(envFrom) ? /** @type {unknown[]} */ (envFrom) : []) {
2153
- if (ef !== null && typeof ef === 'object' && !Array.isArray(ef)) {
2154
- const cmr = /** @type {Record<string, unknown>} */ (ef).configMapRef
2155
- if (cmr !== null && typeof cmr === 'object' && !Array.isArray(cmr)) {
2156
- const n = /** @type {Record<string, unknown>} */ (cmr).name
2157
- if (typeof n === 'string' && n.trim() !== '') names.add(n)
2158
- }
2159
- }
2160
- }
2161
- }
2162
- for (const v of Array.isArray(ps.volumes) ? /** @type {unknown[]} */ (ps.volumes) : []) {
2163
- if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
2164
- const cm = /** @type {Record<string, unknown>} */ (v).configMap
2165
- if (cm !== null && typeof cm === 'object' && !Array.isArray(cm)) {
2166
- const n = /** @type {Record<string, unknown>} */ (cm).name
2167
- if (typeof n === 'string' && n.trim() !== '') names.add(n)
2168
- }
2169
- }
2285
+ const ps = extractPodSpec(deployment)
2286
+ if (ps === null) return names
2287
+ const containers = Array.isArray(ps.containers) ? /** @type {unknown[]} */ (ps.containers) : []
2288
+ for (const c of containers) {
2289
+ collectConfigMapRefsFromContainer(c, names)
2170
2290
  }
2291
+ const volumes = Array.isArray(ps.volumes) ? /** @type {unknown[]} */ (ps.volumes) : []
2292
+ collectConfigMapRefsFromVolumes(volumes, names)
2171
2293
  return names
2172
2294
  }
2173
2295
 
@@ -3374,6 +3496,46 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
3374
3496
 
3375
3497
  const CONFIGMAP_BASE_PATH_RE = /\/k8s\/base\/configmap\.yaml$/u
3376
3498
 
3499
+ /**
3500
+ * Витягує `metadata.name` першого **ConfigMap** із YAML-вмісту.
3501
+ * @param {string} raw вміст YAML-файлу
3502
+ * @returns {string | null} ім'я ConfigMap або null (якщо не знайдено або помилка парсингу)
3503
+ */
3504
+ function extractFirstConfigMapName(raw) {
3505
+ const docs = tryParseAllYamlDocs(raw)
3506
+ if (docs === undefined) return null
3507
+ const cm = findFirstDocByKind(docs, 'ConfigMap')
3508
+ if (cm === null) return null
3509
+ return manifestMetadataName(cm)
3510
+ }
3511
+
3512
+ /**
3513
+ * Перевіряє один файл `configmap.yaml`: якщо поруч є Deployment з рівно одним ConfigMap-рефом,
3514
+ * `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment.
3515
+ * @param {string} cmAbs абсолютний шлях до configmap.yaml
3516
+ * @param {string} rel відносний шлях для повідомлень
3517
+ * @param {(msg: string) => void} fail callback при помилці
3518
+ * @param {(msg: string) => void} passFn callback при успіху
3519
+ */
3520
+ async function validateSingleConfigMapNameMatch(cmAbs, rel, fail, passFn) {
3521
+ const raw = await tryReadFileUtf8(cmAbs)
3522
+ if (raw === undefined) return
3523
+ const cmName = extractFirstConfigMapName(raw)
3524
+ if (cmName === null) return
3525
+ const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3526
+ if (deployment === null) return
3527
+ const deployName = manifestMetadataName(deployment)
3528
+ const cmRefs = collectDeploymentConfigMapRefs(deployment)
3529
+ if (cmRefs.size !== 1 || typeof deployName !== 'string') return
3530
+ if (cmName === deployName) {
3531
+ passFn(`${rel}: metadata.name '${cmName}' збігається з Deployment (k8s.mdc)`)
3532
+ } else {
3533
+ fail(
3534
+ `${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
3535
+ )
3536
+ }
3537
+ }
3538
+
3377
3539
  /**
3378
3540
  * Якщо в `k8s/base/` є `configmap.yaml` і Deployment посилається рівно на один ConfigMap —
3379
3541
  * `metadata.name` ConfigMap має збігатися з `metadata.name` Deployment (k8s.mdc).
@@ -3389,49 +3551,7 @@ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail,
3389
3551
  })
3390
3552
  for (const cmAbs of cmFiles) {
3391
3553
  const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
3392
- let raw
3393
- try {
3394
- raw = await readFile(cmAbs, 'utf8')
3395
- } catch {
3396
- continue
3397
- }
3398
- let cmName = null
3399
- try {
3400
- for (const doc of parseAllDocuments(raw)) {
3401
- if (doc.errors.length > 0) continue
3402
- const obj = doc.toJSON()
3403
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3404
- const rec = /** @type {Record<string, unknown>} */ (obj)
3405
- if (rec.kind === 'ConfigMap') {
3406
- const meta = rec.metadata
3407
- if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
3408
- const n = /** @type {Record<string, unknown>} */ (meta).name
3409
- if (typeof n === 'string' && n.trim() !== '') cmName = n
3410
- }
3411
- break
3412
- }
3413
- }
3414
- }
3415
- } catch {
3416
- continue
3417
- }
3418
- if (cmName === null) continue
3419
- const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3420
- if (deployment === null) continue
3421
- const meta = deployment.metadata
3422
- const deployName =
3423
- meta !== null && typeof meta === 'object' && !Array.isArray(meta)
3424
- ? /** @type {Record<string, unknown>} */ (meta).name
3425
- : null
3426
- const cmRefs = collectDeploymentConfigMapRefs(deployment)
3427
- if (cmRefs.size !== 1 || typeof deployName !== 'string') continue
3428
- if (cmName === deployName) {
3429
- passFn(`${rel}: metadata.name '${cmName}' збігається з Deployment (k8s.mdc)`)
3430
- } else {
3431
- fail(
3432
- `${rel}: metadata.name '${cmName}' має збігатися з назвою Deployment '${deployName}' — Deployment посилається рівно на один ConfigMap (k8s.mdc)`
3433
- )
3434
- }
3554
+ await validateSingleConfigMapNameMatch(cmAbs, rel, fail, passFn)
3435
3555
  }
3436
3556
  }
3437
3557
 
@@ -3441,27 +3561,11 @@ async function validateConfigMapNameMatchesDeployment(root, yamlFilesAbs, fail,
3441
3561
  * @returns {Promise<Record<string, unknown> | null>} об'єкт ConfigMap або null
3442
3562
  */
3443
3563
  async function readFirstConfigMapDoc(absPath) {
3444
- let raw
3445
- try {
3446
- raw = await readFile(absPath, 'utf8')
3447
- } catch {
3448
- return null
3449
- }
3450
- let docs
3451
- try {
3452
- docs = parseAllDocuments(raw)
3453
- } catch {
3454
- return null
3455
- }
3456
- for (const doc of docs) {
3457
- if (doc.errors.length > 0) continue
3458
- const obj = doc.toJSON()
3459
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3460
- const rec = /** @type {Record<string, unknown>} */ (obj)
3461
- if (rec.kind === 'ConfigMap') return rec
3462
- }
3463
- }
3464
- return null
3564
+ const raw = await tryReadFileUtf8(absPath)
3565
+ if (raw === undefined) return null
3566
+ const docs = tryParseAllYamlDocs(raw)
3567
+ if (docs === undefined) return null
3568
+ return findFirstDocByKind(docs, 'ConfigMap')
3465
3569
  }
3466
3570
 
3467
3571
  /**
@@ -3480,14 +3584,16 @@ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs
3480
3584
  for (const cmAbs of cmFiles) {
3481
3585
  const rel = relative(root, cmAbs).replaceAll('\\', '/') || cmAbs
3482
3586
  const deployment = await findDeploymentDocInDir(dirname(cmAbs))
3483
- if (deployment === null || !isHasuraDeploymentManifest(deployment)) continue
3484
- const cm = await readFirstConfigMapDoc(cmAbs)
3485
- if (cm === null) continue
3486
- const violation = hasuraConfigMapRemoteSchemaPermissionsViolation(cm)
3487
- if (violation !== null) {
3488
- fail(`${rel}: ${violation}`)
3489
- } else {
3490
- passFn(`${rel}: ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" для Hasura-Deployment (k8s.mdc)`)
3587
+ if (deployment !== null && isHasuraDeploymentManifest(deployment)) {
3588
+ const cm = await readFirstConfigMapDoc(cmAbs)
3589
+ if (cm !== null) {
3590
+ const violation = hasuraConfigMapRemoteSchemaPermissionsViolation(cm)
3591
+ if (violation === null) {
3592
+ passFn(`${rel}: ${HASURA_REMOTE_SCHEMA_PERMISSIONS_KEY}="true" для Hasura-Deployment (k8s.mdc)`)
3593
+ } else {
3594
+ fail(`${rel}: ${violation}`)
3595
+ }
3596
+ }
3491
3597
  }
3492
3598
  }
3493
3599
  }
@@ -3514,7 +3620,7 @@ const TOPOLOGY_SPREAD_TOPOLOGY_KEY = 'kubernetes.io/hostname'
3514
3620
  * @returns {string | null} сегмент середовища або null, якщо `/k8s/` немає в шляху
3515
3621
  */
3516
3622
  export function k8sEnvSegmentFromRelPath(relPath) {
3517
- const m = relPath.match(/(?:^|\/)k8s\/([^/]+)(?:\/|$)/u)
3623
+ const m = relPath.match(K8S_ENV_SEGMENT_RE)
3518
3624
  return m ? m[1] : null
3519
3625
  }
3520
3626
 
@@ -3566,10 +3672,100 @@ export function deploymentAppLabel(deployment) {
3566
3672
  */
3567
3673
  function coerceInteger(v) {
3568
3674
  if (typeof v === 'number' && Number.isInteger(v)) return v
3569
- if (typeof v === 'string' && /^-?\d+$/u.test(v.trim())) return Number.parseInt(v, 10)
3675
+ if (typeof v === 'string' && INTEGER_STRING_RE.test(v.trim())) return Number.parseInt(v, 10)
3570
3676
  return null
3571
3677
  }
3572
3678
 
3679
+ /**
3680
+ * Перевіряє `spec.scaleTargetRef` у HPA і додає порушення до масиву.
3681
+ * @param {Record<string, unknown>} spec об'єкт `spec` HPA
3682
+ * @param {string} expectedDeployName очікуване ім'я Deployment
3683
+ * @param {string[]} errs масив порушень
3684
+ */
3685
+ function validateHpaScaleTargetRef(spec, expectedDeployName, errs) {
3686
+ const str = spec.scaleTargetRef
3687
+ if (str === null || str === undefined || typeof str !== 'object' || Array.isArray(str)) {
3688
+ errs.push('spec.scaleTargetRef відсутній')
3689
+ return
3690
+ }
3691
+ const r = /** @type {Record<string, unknown>} */ (str)
3692
+ if (r.apiVersion !== 'apps/v1')
3693
+ errs.push(`spec.scaleTargetRef.apiVersion має бути apps/v1 (зараз: ${JSON.stringify(r.apiVersion)})`)
3694
+ if (r.kind !== 'Deployment')
3695
+ errs.push(`spec.scaleTargetRef.kind має бути Deployment (зараз: ${JSON.stringify(r.kind)})`)
3696
+ if (r.name !== expectedDeployName)
3697
+ errs.push(`spec.scaleTargetRef.name має бути '${expectedDeployName}' (зараз: ${JSON.stringify(r.name)})`)
3698
+ }
3699
+
3700
+ /**
3701
+ * Перевіряє dev-like межі `minReplicas` / `maxReplicas` HPA (обидва мають бути рівно 1).
3702
+ * @param {number | null} minR значення minReplicas
3703
+ * @param {number | null} maxR значення maxReplicas
3704
+ * @param {string[]} errs масив порушень
3705
+ */
3706
+ function validateHpaDevLikeReplicas(minR, maxR, errs) {
3707
+ if (minR !== null && minR !== 1)
3708
+ errs.push(`spec.minReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${minR})`)
3709
+ if (maxR !== null && maxR !== 1)
3710
+ errs.push(`spec.maxReplicas для dev-like (base/dev/*-qa) має бути 1 (зараз: ${maxR})`)
3711
+ }
3712
+
3713
+ /**
3714
+ * Перевіряє прод межі `minReplicas` / `maxReplicas` HPA (обидва мають бути мінімум 2).
3715
+ * @param {number | null} minR значення minReplicas
3716
+ * @param {number | null} maxR значення maxReplicas
3717
+ * @param {string[]} errs масив порушень
3718
+ */
3719
+ function validateHpaProdReplicas(minR, maxR, errs) {
3720
+ if (minR !== null && minR < 2) errs.push(`spec.minReplicas для прод середовища має бути мінімум 2 (зараз: ${minR})`)
3721
+ if (maxR !== null && maxR < 2) errs.push(`spec.maxReplicas для прод середовища має бути мінімум 2 (зараз: ${maxR})`)
3722
+ }
3723
+
3724
+ /**
3725
+ * Перевіряє env-залежні межі `minReplicas` / `maxReplicas` HPA.
3726
+ * @param {number | null} minR значення minReplicas
3727
+ * @param {number | null} maxR значення maxReplicas
3728
+ * @param {boolean} isDevLike чи середовище dev-like
3729
+ * @param {string[]} errs масив порушень
3730
+ */
3731
+ function validateHpaReplicaLimits(minR, maxR, isDevLike, errs) {
3732
+ if (minR === null) errs.push('spec.minReplicas має бути цілим числом')
3733
+ if (maxR === null) errs.push('spec.maxReplicas має бути цілим числом')
3734
+ if (minR !== null && maxR !== null && minR > maxR) {
3735
+ errs.push(`spec.minReplicas (${minR}) не може бути більше spec.maxReplicas (${maxR})`)
3736
+ }
3737
+ if (isDevLike) {
3738
+ validateHpaDevLikeReplicas(minR, maxR, errs)
3739
+ } else {
3740
+ validateHpaProdReplicas(minR, maxR, errs)
3741
+ }
3742
+ }
3743
+
3744
+ /**
3745
+ * Перевіряє `spec.behavior` HPA (наявність scaleUp/scaleDown з policies).
3746
+ * @param {Record<string, unknown>} spec об'єкт `spec` HPA
3747
+ * @param {string[]} errs масив порушень
3748
+ */
3749
+ function validateHpaBehavior(spec, errs) {
3750
+ const behavior = spec.behavior
3751
+ if (behavior === null || behavior === undefined || typeof behavior !== 'object' || Array.isArray(behavior)) {
3752
+ errs.push('spec.behavior відсутній (має містити scaleUp і scaleDown)')
3753
+ return
3754
+ }
3755
+ const b = /** @type {Record<string, unknown>} */ (behavior)
3756
+ for (const key of /** @type {const} */ (['scaleUp', 'scaleDown'])) {
3757
+ const v = b[key]
3758
+ if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) {
3759
+ errs.push(`spec.behavior.${key} відсутній`)
3760
+ } else {
3761
+ const policies = /** @type {Record<string, unknown>} */ (v).policies
3762
+ if (!Array.isArray(policies) || policies.length === 0) {
3763
+ errs.push(`spec.behavior.${key}.policies має бути непорожнім масивом`)
3764
+ }
3765
+ }
3766
+ }
3767
+ }
3768
+
3573
3769
  /**
3574
3770
  * Перевіряє **HPA** (`autoscaling/v2`, `HorizontalPodAutoscaler`): структура й env-залежні межі
3575
3771
  * minReplicas / maxReplicas (**dev-like:** `minReplicas === 1`; **прод:** `minReplicas >= 2`, `maxReplicas >= 2`).
@@ -3586,61 +3782,68 @@ export function hpaManifestViolations(manifest, expectedDeployName, isDevLike) {
3586
3782
  return errs
3587
3783
  }
3588
3784
  const rec = /** @type {Record<string, unknown>} */ (manifest)
3589
- if (rec.kind !== 'HorizontalPodAutoscaler') errs.push(`kind має бути HorizontalPodAutoscaler (зараз: ${JSON.stringify(rec.kind)})`)
3590
- if (rec.apiVersion !== 'autoscaling/v2') errs.push(`apiVersion має бути autoscaling/v2 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3785
+ if (rec.kind !== 'HorizontalPodAutoscaler')
3786
+ errs.push(`kind має бути HorizontalPodAutoscaler (зараз: ${JSON.stringify(rec.kind)})`)
3787
+ if (rec.apiVersion !== 'autoscaling/v2')
3788
+ errs.push(`apiVersion має бути autoscaling/v2 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3591
3789
  const spec = rec.spec
3592
3790
  if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
3593
3791
  errs.push('spec відсутній або некоректний')
3594
3792
  return errs
3595
3793
  }
3596
3794
  const s = /** @type {Record<string, unknown>} */ (spec)
3597
- const str = s.scaleTargetRef
3598
- if (str === null || str === undefined || typeof str !== 'object' || Array.isArray(str)) {
3599
- errs.push('spec.scaleTargetRef відсутній')
3600
- } else {
3601
- const r = /** @type {Record<string, unknown>} */ (str)
3602
- if (r.apiVersion !== 'apps/v1') errs.push(`spec.scaleTargetRef.apiVersion має бути apps/v1 (зараз: ${JSON.stringify(r.apiVersion)})`)
3603
- if (r.kind !== 'Deployment') errs.push(`spec.scaleTargetRef.kind має бути Deployment (зараз: ${JSON.stringify(r.kind)})`)
3604
- if (r.name !== expectedDeployName)
3605
- errs.push(`spec.scaleTargetRef.name має бути '${expectedDeployName}' (зараз: ${JSON.stringify(r.name)})`)
3606
- }
3607
- const minR = coerceInteger(s.minReplicas)
3608
- const maxR = coerceInteger(s.maxReplicas)
3609
- if (minR === null) errs.push('spec.minReplicas має бути цілим числом')
3610
- if (maxR === null) errs.push('spec.maxReplicas має бути цілим числом')
3611
- if (minR !== null && maxR !== null && minR > maxR) {
3612
- errs.push(`spec.minReplicas (${minR}) не може бути більше spec.maxReplicas (${maxR})`)
3613
- }
3614
- if (isDevLike) {
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})`)
3617
- } else {
3618
- if (minR !== null && minR < 2) errs.push(`spec.minReplicas для прод середовища має бути мінімум 2 (зараз: ${minR})`)
3619
- if (maxR !== null && maxR < 2) errs.push(`spec.maxReplicas для прод середовища має бути мінімум 2 (зараз: ${maxR})`)
3620
- }
3795
+ validateHpaScaleTargetRef(s, expectedDeployName, errs)
3796
+ validateHpaReplicaLimits(coerceInteger(s.minReplicas), coerceInteger(s.maxReplicas), isDevLike, errs)
3621
3797
  if (!Array.isArray(s.metrics) || s.metrics.length === 0) {
3622
3798
  errs.push('spec.metrics має бути непорожнім масивом (наприклад, Resource/cpu/Utilization)')
3623
3799
  }
3624
- const behavior = s.behavior
3625
- if (behavior === null || behavior === undefined || typeof behavior !== 'object' || Array.isArray(behavior)) {
3626
- errs.push('spec.behavior відсутній (має містити scaleUp і scaleDown)')
3627
- } else {
3628
- const b = /** @type {Record<string, unknown>} */ (behavior)
3629
- for (const key of /** @type {const} */ (['scaleUp', 'scaleDown'])) {
3630
- const v = b[key]
3631
- if (v === null || v === undefined || typeof v !== 'object' || Array.isArray(v)) {
3632
- errs.push(`spec.behavior.${key} відсутній`)
3633
- continue
3634
- }
3635
- const policies = /** @type {Record<string, unknown>} */ (v).policies
3636
- if (!Array.isArray(policies) || policies.length === 0) {
3637
- errs.push(`spec.behavior.${key}.policies має бути непорожнім масивом`)
3638
- }
3639
- }
3640
- }
3800
+ validateHpaBehavior(s, errs)
3641
3801
  return errs
3642
3802
  }
3643
3803
 
3804
+ /**
3805
+ * Перевіряє env-залежну межу `minAvailable` у PDB.
3806
+ * @param {number | null} minA значення minAvailable
3807
+ * @param {boolean} isDevLike чи середовище dev-like
3808
+ * @param {string[]} errs масив порушень
3809
+ */
3810
+ function validatePdbMinAvailable(minA, isDevLike, errs) {
3811
+ if (minA === null) {
3812
+ errs.push('spec.minAvailable має бути цілим числом')
3813
+ } else if (isDevLike) {
3814
+ if (minA !== 0) errs.push(`spec.minAvailable для dev-like (base/dev/*-qa) має бути 0 (зараз: ${minA})`)
3815
+ } else if (minA < 1) {
3816
+ errs.push(`spec.minAvailable для прод середовища має бути мінімум 1 (зараз: ${minA})`)
3817
+ }
3818
+ }
3819
+
3820
+ /**
3821
+ * Перевіряє `spec.selector.matchLabels.app` у PDB.
3822
+ * @param {Record<string, unknown>} spec об'єкт `spec` PDB
3823
+ * @param {string} expectedAppLabel очікувана мітка `app`
3824
+ * @param {string[]} errs масив порушень
3825
+ */
3826
+ function validatePdbSelector(spec, expectedAppLabel, errs) {
3827
+ const selector = spec.selector
3828
+ if (selector === null || selector === undefined || typeof selector !== 'object' || Array.isArray(selector)) {
3829
+ errs.push('spec.selector відсутній')
3830
+ return
3831
+ }
3832
+ const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
3833
+ if (
3834
+ matchLabels === null ||
3835
+ matchLabels === undefined ||
3836
+ typeof matchLabels !== 'object' ||
3837
+ Array.isArray(matchLabels)
3838
+ ) {
3839
+ errs.push('spec.selector.matchLabels відсутній')
3840
+ return
3841
+ }
3842
+ const app = /** @type {Record<string, unknown>} */ (matchLabels).app
3843
+ if (app !== expectedAppLabel)
3844
+ errs.push(`spec.selector.matchLabels.app має бути '${expectedAppLabel}' (зараз: ${JSON.stringify(app)})`)
3845
+ }
3846
+
3644
3847
  /**
3645
3848
  * Перевіряє **PDB** (`policy/v1`, `PodDisruptionBudget`): структура й env-залежна межа
3646
3849
  * minAvailable (**dev-like:** `=== 0`; **прод:** `>= 1`).
@@ -3657,38 +3860,40 @@ export function pdbManifestViolations(manifest, expectedAppLabel, isDevLike) {
3657
3860
  return errs
3658
3861
  }
3659
3862
  const rec = /** @type {Record<string, unknown>} */ (manifest)
3660
- if (rec.kind !== 'PodDisruptionBudget') errs.push(`kind має бути PodDisruptionBudget (зараз: ${JSON.stringify(rec.kind)})`)
3661
- if (rec.apiVersion !== 'policy/v1') errs.push(`apiVersion має бути policy/v1 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3863
+ if (rec.kind !== 'PodDisruptionBudget')
3864
+ errs.push(`kind має бути PodDisruptionBudget (зараз: ${JSON.stringify(rec.kind)})`)
3865
+ if (rec.apiVersion !== 'policy/v1')
3866
+ errs.push(`apiVersion має бути policy/v1 (зараз: ${JSON.stringify(rec.apiVersion)})`)
3662
3867
  const spec = rec.spec
3663
3868
  if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) {
3664
3869
  errs.push('spec відсутній або некоректний')
3665
3870
  return errs
3666
3871
  }
3667
3872
  const s = /** @type {Record<string, unknown>} */ (spec)
3668
- const minA = coerceInteger(s.minAvailable)
3669
- if (minA === null) {
3670
- errs.push('spec.minAvailable має бути цілим числом')
3671
- } else if (isDevLike) {
3672
- if (minA !== 0) errs.push(`spec.minAvailable для dev-like (base/dev/*-qa) має бути 0 (зараз: ${minA})`)
3673
- } else if (minA < 1) {
3674
- errs.push(`spec.minAvailable для прод середовища має бути мінімум 1 (зараз: ${minA})`)
3675
- }
3676
- const selector = s.selector
3677
- if (selector === null || selector === undefined || typeof selector !== 'object' || Array.isArray(selector)) {
3678
- errs.push('spec.selector відсутній')
3679
- } else {
3680
- const matchLabels = /** @type {Record<string, unknown>} */ (selector).matchLabels
3681
- if (matchLabels === null || matchLabels === undefined || typeof matchLabels !== 'object' || Array.isArray(matchLabels)) {
3682
- errs.push('spec.selector.matchLabels відсутній')
3683
- } else {
3684
- const app = /** @type {Record<string, unknown>} */ (matchLabels).app
3685
- if (app !== expectedAppLabel)
3686
- errs.push(`spec.selector.matchLabels.app має бути '${expectedAppLabel}' (зараз: ${JSON.stringify(app)})`)
3687
- }
3688
- }
3873
+ validatePdbMinAvailable(coerceInteger(s.minAvailable), isDevLike, errs)
3874
+ validatePdbSelector(s, expectedAppLabel, errs)
3689
3875
  return errs
3690
3876
  }
3691
3877
 
3878
+ /**
3879
+ * Чи елемент `topologySpreadConstraints` відповідає канону (maxSkew=1, topologyKey, whenUnsatisfiable, app label).
3880
+ * @param {unknown} item елемент масиву topologySpreadConstraints
3881
+ * @param {string} expectedAppLabel очікувана мітка `app`
3882
+ * @returns {boolean} true, якщо збіг канонічний
3883
+ */
3884
+ function isCanonicalTopologySpreadConstraint(item, expectedAppLabel) {
3885
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return false
3886
+ const it = /** @type {Record<string, unknown>} */ (item)
3887
+ if (coerceInteger(it.maxSkew) !== 1) return false
3888
+ if (it.topologyKey !== TOPOLOGY_SPREAD_TOPOLOGY_KEY) return false
3889
+ if (it.whenUnsatisfiable !== 'ScheduleAnyway') return false
3890
+ const ls = getNestedObject(it, 'labelSelector')
3891
+ if (ls === null) return false
3892
+ const ml = getNestedObject(ls, 'matchLabels')
3893
+ if (ml === null) return false
3894
+ return ml.app === expectedAppLabel
3895
+ }
3896
+
3692
3897
  /**
3693
3898
  * Перевіряє, що Deployment має канонічний запис у **`spec.template.spec.topologySpreadConstraints`**:
3694
3899
  * `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`,
@@ -3702,36 +3907,41 @@ export function deploymentTopologySpreadConstraintsViolation(manifest, expectedA
3702
3907
  return null
3703
3908
  const rec = /** @type {Record<string, unknown>} */ (manifest)
3704
3909
  if (rec.kind !== 'Deployment') return null
3705
- const spec = rec.spec
3706
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
3707
- return 'spec відсутній'
3708
- const template = /** @type {Record<string, unknown>} */ (spec).template
3709
- if (template === null || typeof template !== 'object' || Array.isArray(template))
3710
- return 'spec.template відсутній'
3711
- const podSpec = /** @type {Record<string, unknown>} */ (template).spec
3712
- if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec))
3713
- return 'spec.template.spec відсутній'
3714
- const tsc = /** @type {Record<string, unknown>} */ (podSpec).topologySpreadConstraints
3715
- if (!Array.isArray(tsc) || tsc.length === 0) {
3716
- return `spec.template.spec.topologySpreadConstraints: додай запис maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
3717
- }
3910
+ const podSpec = extractPodSpec(rec)
3911
+ if (podSpec === null) return 'spec.template.spec відсутній'
3912
+ const tsc = podSpec.topologySpreadConstraints
3913
+ const expectedMsg = `spec.template.spec.topologySpreadConstraints: додай запис maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
3914
+ if (!Array.isArray(tsc) || tsc.length === 0) return expectedMsg
3718
3915
  for (const item of tsc) {
3719
- if (item === null || typeof item !== 'object' || Array.isArray(item)) continue
3720
- const it = /** @type {Record<string, unknown>} */ (item)
3721
- if (coerceInteger(it.maxSkew) !== 1) continue
3722
- if (it.topologyKey !== TOPOLOGY_SPREAD_TOPOLOGY_KEY) continue
3723
- if (it.whenUnsatisfiable !== 'ScheduleAnyway') continue
3724
- const ls = it.labelSelector
3725
- if (ls === null || typeof ls !== 'object' || Array.isArray(ls)) continue
3726
- const ml = /** @type {Record<string, unknown>} */ (ls).matchLabels
3727
- if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) continue
3728
- if (/** @type {Record<string, unknown>} */ (ml).app === expectedAppLabel) {
3729
- return null
3730
- }
3916
+ if (isCanonicalTopologySpreadConstraint(item, expectedAppLabel)) return null
3731
3917
  }
3732
3918
  return `spec.template.spec.topologySpreadConstraints: бракує запису maxSkew=1, topologyKey=${TOPOLOGY_SPREAD_TOPOLOGY_KEY}, whenUnsatisfiable=ScheduleAnyway, labelSelector.matchLabels.app='${expectedAppLabel}' (k8s.mdc)`
3733
3919
  }
3734
3920
 
3921
+ /**
3922
+ * Читає YAML-файл і збирає всі документи із заданим `kind`.
3923
+ * @param {string} filePath абсолютний шлях до YAML-файлу
3924
+ * @param {string} kind очікуваний `kind`
3925
+ * @returns {Promise<Record<string, unknown>[]>} знайдені об'єкти (порожній масив, якщо файл недоступний або парсинг не вдався)
3926
+ */
3927
+ async function readAllDocsByKindFromFile(filePath, kind) {
3928
+ const raw = await tryReadFileUtf8(filePath)
3929
+ if (raw === undefined) return []
3930
+ const docs = tryParseAllYamlDocs(raw)
3931
+ if (docs === undefined) return []
3932
+ return collectDocsByKind(docs, kind)
3933
+ }
3934
+
3935
+ /**
3936
+ * Чи ім'я файлу відповідає фільтру YAML-розширення або точному basename.
3937
+ * @param {string} entry ім'я файлу
3938
+ * @param {string} [filenameFilter] точний basename або undefined для перевірки за YAML-розширенням
3939
+ * @returns {boolean} true, якщо файл підходить
3940
+ */
3941
+ function matchesYamlFilter(entry, filenameFilter) {
3942
+ return filenameFilter === undefined ? K8S_YAML_EXT_RE.test(entry) : entry === filenameFilter
3943
+ }
3944
+
3735
3945
  /**
3736
3946
  * Збирає всі документи з **k8s**-yaml за заданим `kind` у каталозі.
3737
3947
  * @param {string} dirPath абсолютний шлях до каталогу
@@ -3742,37 +3952,11 @@ export function deploymentTopologySpreadConstraintsViolation(manifest, expectedA
3742
3952
  async function readDocsByKindInDir(dirPath, kind, filenameFilter) {
3743
3953
  /** @type {Record<string, unknown>[]} */
3744
3954
  const out = []
3745
- let entries
3746
- try {
3747
- entries = await readdir(dirPath)
3748
- } catch {
3749
- return out
3750
- }
3955
+ const entries = await tryReaddir(dirPath)
3751
3956
  for (const entry of entries) {
3752
- if (filenameFilter !== undefined) {
3753
- if (entry !== filenameFilter) continue
3754
- } else if (!K8S_YAML_EXT_RE.test(entry)) {
3755
- continue
3756
- }
3757
- let raw
3758
- try {
3759
- raw = await readFile(join(dirPath, entry), 'utf8')
3760
- } catch {
3761
- continue
3762
- }
3763
- let docs
3764
- try {
3765
- docs = parseAllDocuments(raw)
3766
- } catch {
3767
- continue
3768
- }
3769
- for (const doc of docs) {
3770
- if (doc.errors.length > 0) continue
3771
- const obj = doc.toJSON()
3772
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3773
- const rec = /** @type {Record<string, unknown>} */ (obj)
3774
- if (rec.kind === kind) out.push(rec)
3775
- }
3957
+ if (matchesYamlFilter(entry, filenameFilter)) {
3958
+ const found = await readAllDocsByKindFromFile(join(dirPath, entry), kind)
3959
+ for (const rec of found) out.push(rec)
3776
3960
  }
3777
3961
  }
3778
3962
  return out
@@ -3813,8 +3997,8 @@ export function kustomizePatchModifiedPaths(patchText) {
3813
3997
  if (parsed === null || parsed === undefined || typeof parsed !== 'object' || Array.isArray(parsed)) return out
3814
3998
  /**
3815
3999
  * Рекурсивний обхід: шлях додаємо лише для листків (скаляр / масив).
3816
- * @param {Record<string, unknown>} obj
3817
- * @param {string} prefix
4000
+ * @param {Record<string, unknown>} obj вузол дерева
4001
+ * @param {string} prefix поточний JSON Pointer
3818
4002
  */
3819
4003
  const walk = (obj, prefix) => {
3820
4004
  for (const [k, v] of Object.entries(obj)) {
@@ -3854,6 +4038,37 @@ function strategicMergePatchKind(patchText) {
3854
4038
  return null
3855
4039
  }
3856
4040
 
4041
+ /**
4042
+ * Визначає `kind` цілі для одного inline patch: з `target.kind` або з тіла Strategic Merge.
4043
+ * @param {Record<string, unknown>} patchObj елемент масиву `patches[]`
4044
+ * @returns {string | null} kind або null, якщо не вдалося визначити
4045
+ */
4046
+ function resolvePatchTargetKind(patchObj) {
4047
+ const target = patchObj.target
4048
+ if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
4049
+ const tk = /** @type {Record<string, unknown>} */ (target).kind
4050
+ if (typeof tk === 'string' && tk !== '') return tk
4051
+ }
4052
+ return typeof patchObj.patch === 'string' ? strategicMergePatchKind(patchObj.patch) : null
4053
+ }
4054
+
4055
+ /**
4056
+ * Обробляє один елемент `patches[]` і додає знайдені шляхи до `byKind`.
4057
+ * @param {unknown} p елемент масиву `patches[]`
4058
+ * @param {Map<string, Set<string>>} byKind накопичувач `kind` → шляхи JSON Pointer
4059
+ */
4060
+ function processSingleKustomizePatch(p, byKind) {
4061
+ if (p === null || typeof p !== 'object' || Array.isArray(p)) return
4062
+ const pr = /** @type {Record<string, unknown>} */ (p)
4063
+ if (typeof pr.patch !== 'string') return
4064
+ const kind = resolvePatchTargetKind(pr)
4065
+ if (kind === null) return
4066
+ const paths = kustomizePatchModifiedPaths(pr.patch)
4067
+ if (!byKind.has(kind)) byKind.set(kind, new Set())
4068
+ const set = byKind.get(kind)
4069
+ for (const x of paths) set.add(x)
4070
+ }
4071
+
3857
4072
  /**
3858
4073
  * Збирає шляхи, змінені всіма inline `patches[]` у kustomization, згрупованими за `kind` цілі.
3859
4074
  * `kind` визначається з `target.kind` (канон) або, якщо відсутній — з `kind:` у тілі Strategic Merge patch.
@@ -3866,28 +4081,69 @@ export function kustomizationPatchPathsByTargetKind(kust) {
3866
4081
  const patches = kust.patches
3867
4082
  if (!Array.isArray(patches)) return byKind
3868
4083
  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)
4084
+ processSingleKustomizePatch(p, byKind)
3885
4085
  }
3886
4086
  return byKind
3887
4087
  }
3888
4088
 
3889
4089
  /**
3890
- * Для прод kustomization.yaml вимагає патчі, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
4090
+ * Читає перший валідний YAML-об'єкт із файлу.
4091
+ * @param {string} absPath абсолютний шлях до YAML-файлу
4092
+ * @returns {Promise<Record<string, unknown> | null>} перший об'єкт або null
4093
+ */
4094
+ async function readFirstYamlObject(absPath) {
4095
+ const raw = await tryReadFileUtf8(absPath)
4096
+ if (raw === undefined) return null
4097
+ const docs = tryParseAllYamlDocs(raw)
4098
+ if (docs === undefined) return null
4099
+ for (const doc of docs) {
4100
+ if (doc.errors.length === 0) {
4101
+ const obj = doc.toJSON()
4102
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
4103
+ return /** @type {Record<string, unknown>} */ (obj)
4104
+ }
4105
+ }
4106
+ }
4107
+ return null
4108
+ }
4109
+
4110
+ /**
4111
+ * Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
4112
+ * @param {Record<string, unknown>} kust об'єкт kustomization
4113
+ * @param {string} rel відносний шлях для повідомлень
4114
+ * @param {(msg: string) => void} fail callback при помилці
4115
+ * @param {(msg: string) => void} passFn callback при успіху
4116
+ */
4117
+ function checkProdOverridesInKustomization(kust, rel, fail, passFn) {
4118
+ const byKind = kustomizationPatchPathsByTargetKind(kust)
4119
+ const hpaPaths = byKind.get('HorizontalPodAutoscaler') ?? new Set()
4120
+ const pdbPaths = byKind.get('PodDisruptionBudget') ?? new Set()
4121
+ let ok = true
4122
+ if (!hpaPaths.has('/spec/minReplicas')) {
4123
+ fail(
4124
+ `${rel}: прод-оверлей має перевизначати spec.minReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
4125
+ )
4126
+ ok = false
4127
+ }
4128
+ if (!hpaPaths.has('/spec/maxReplicas')) {
4129
+ fail(
4130
+ `${rel}: прод-оверлей має перевизначати spec.maxReplicas для HorizontalPodAutoscaler (мінімум 2 у проді) (k8s.mdc)`
4131
+ )
4132
+ ok = false
4133
+ }
4134
+ if (!pdbPaths.has('/spec/minAvailable')) {
4135
+ fail(
4136
+ `${rel}: прод-оверлей має перевизначати spec.minAvailable для PodDisruptionBudget (мінімум 1 у проді) (k8s.mdc)`
4137
+ )
4138
+ ok = false
4139
+ }
4140
+ if (ok) {
4141
+ passFn(`${rel}: прод-оверрайди HPA minReplicas/maxReplicas і PDB minAvailable присутні (k8s.mdc)`)
4142
+ }
4143
+ }
4144
+
4145
+ /**
4146
+ * Для прод kustomization.yaml вимагає patches, що перевизначають **`/spec/minReplicas`** і **`/spec/maxReplicas`**
3891
4147
  * на **HorizontalPodAutoscaler**, а також **`/spec/minAvailable`** на **PodDisruptionBudget**. Не застосовується
3892
4148
  * до dev-like (base / dev / *-qa) — там ці значення беруть з base (див. k8s.mdc).
3893
4149
  * @param {string} root корінь репозиторію
@@ -3900,57 +4156,164 @@ async function validateProdKustomizationOverrides(root, yamlFilesAbs, fail, pass
3900
4156
  for (const kustAbs of kustFiles) {
3901
4157
  const rel = relative(root, kustAbs).replaceAll('\\', '/')
3902
4158
  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
- }
4159
+ if (segment !== null && !isDevLikeK8sEnvSegment(segment)) {
4160
+ const kust = await readFirstYamlObject(kustAbs)
4161
+ if (kust !== null) {
4162
+ checkProdOverridesInKustomization(kust, rel, fail, passFn)
3921
4163
  }
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
4164
  }
3951
4165
  }
3952
4166
  }
3953
4167
 
4168
+ /**
4169
+ * Шукає HPA за `scaleTargetRef.name` серед документів.
4170
+ * @param {Record<string, unknown>[]} hpaDocs масив HPA-документів
4171
+ * @param {string} deployName ім'я Deployment
4172
+ * @returns {Record<string, unknown> | undefined} знайдений HPA або undefined
4173
+ */
4174
+ function findHpaByDeployName(hpaDocs, deployName) {
4175
+ return hpaDocs.find(h => {
4176
+ const spec = getNestedObject(h, 'spec')
4177
+ if (spec === null) return false
4178
+ const str = getNestedObject(spec, 'scaleTargetRef')
4179
+ if (str === null) return false
4180
+ return str.name === deployName
4181
+ })
4182
+ }
4183
+
4184
+ /**
4185
+ * Шукає PDB за `selector.matchLabels.app` серед документів.
4186
+ * @param {Record<string, unknown>[]} pdbDocs масив PDB-документів
4187
+ * @param {string} appLabel очікувана мітка `app`
4188
+ * @returns {Record<string, unknown> | undefined} знайдений PDB або undefined
4189
+ */
4190
+ function findPdbByAppLabel(pdbDocs, appLabel) {
4191
+ return pdbDocs.find(p => {
4192
+ const spec = getNestedObject(p, 'spec')
4193
+ if (spec === null) return false
4194
+ const selector = getNestedObject(spec, 'selector')
4195
+ if (selector === null) return false
4196
+ const ml = getNestedObject(selector, 'matchLabels')
4197
+ if (ml === null) return false
4198
+ return ml.app === appLabel
4199
+ })
4200
+ }
4201
+
4202
+ /**
4203
+ * Перевіряє HPA для одного Deployment: наявність, відповідність spec, env-залежні межі.
4204
+ * @param {Record<string, unknown>[]} hpaDocs масив HPA-документів каталогу
4205
+ * @param {string} deployName ім'я Deployment
4206
+ * @param {boolean} isDevLike чи середовище dev-like
4207
+ * @param {string} hpaRel відносний шлях до hpa.yaml для повідомлень
4208
+ * @param {(msg: string) => void} fail callback при помилці
4209
+ * @param {(msg: string) => void} passFn callback при успіху
4210
+ */
4211
+ function validateHpaForDeployment(hpaDocs, deployName, isDevLike, hpaRel, fail, passFn) {
4212
+ const matchedHpa = findHpaByDeployName(hpaDocs, deployName)
4213
+ if (matchedHpa === undefined) {
4214
+ fail(
4215
+ `${hpaRel}: відсутній або не знайдено HPA зі scaleTargetRef.name='${deployName}' поруч із Deployment (k8s.mdc)`
4216
+ )
4217
+ return
4218
+ }
4219
+ const hpaErrs = hpaManifestViolations(matchedHpa, deployName, isDevLike)
4220
+ if (hpaErrs.length === 0) {
4221
+ passFn(`${hpaRel}: HPA для Deployment '${deployName}' валідний (k8s.mdc)`)
4222
+ } else {
4223
+ for (const e of hpaErrs) fail(`${hpaRel}: ${e} (k8s.mdc)`)
4224
+ }
4225
+ }
4226
+
4227
+ /**
4228
+ * Перевіряє PDB для одного Deployment: наявність, відповідність selector, env-залежні межі.
4229
+ * @param {Record<string, unknown>[]} pdbDocs масив PDB-документів каталогу
4230
+ * @param {string} deployName ім'я Deployment
4231
+ * @param {string} appLabel мітка `app` Deployment
4232
+ * @param {boolean} isDevLike чи середовище dev-like
4233
+ * @param {string} pdbRel відносний шлях до pdb.yaml для повідомлень
4234
+ * @param {(msg: string) => void} fail callback при помилці
4235
+ * @param {(msg: string) => void} passFn callback при успіху
4236
+ */
4237
+ function validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, pdbRel, fail, passFn) {
4238
+ const matchedPdb = findPdbByAppLabel(pdbDocs, appLabel)
4239
+ if (matchedPdb === undefined) {
4240
+ fail(
4241
+ `${pdbRel}: відсутній або не знайдено PDB зі selector.matchLabels.app='${appLabel}' поруч із Deployment (k8s.mdc)`
4242
+ )
4243
+ return
4244
+ }
4245
+ const pdbErrs = pdbManifestViolations(matchedPdb, appLabel, isDevLike)
4246
+ if (pdbErrs.length === 0) {
4247
+ passFn(`${pdbRel}: PDB для Deployment '${deployName}' валідний (k8s.mdc)`)
4248
+ } else {
4249
+ for (const e of pdbErrs) fail(`${pdbRel}: ${e} (k8s.mdc)`)
4250
+ }
4251
+ }
4252
+
4253
+ /**
4254
+ * Перевіряє один Deployment: topologySpreadConstraints, HPA та PDB.
4255
+ * @param {Record<string, unknown>} deployment об'єкт Deployment
4256
+ * @param {string} deployRel відносний шлях каталогу для повідомлень
4257
+ * @param {boolean} isDevLike чи середовище dev-like
4258
+ * @param {Record<string, unknown>[]} hpaDocs HPA-документи каталогу
4259
+ * @param {Record<string, unknown>[]} pdbDocs PDB-документи каталогу
4260
+ * @param {(msg: string) => void} fail callback при помилці
4261
+ * @param {(msg: string) => void} passFn callback при успіху
4262
+ */
4263
+ function validateSingleDeploymentHpaPdbTopology(deployment, deployRel, isDevLike, hpaDocs, pdbDocs, fail, passFn) {
4264
+ const deployName = manifestMetadataName(deployment)
4265
+ const appLabel = deploymentAppLabel(deployment)
4266
+ if (deployName === null) {
4267
+ fail(`${deployRel}: Deployment без metadata.name — не можу перевірити HPA/PDB (k8s.mdc)`)
4268
+ return
4269
+ }
4270
+ if (appLabel === null) {
4271
+ fail(`${deployRel}: Deployment '${deployName}' без spec.selector.matchLabels.app — додай мітку (k8s.mdc)`)
4272
+ return
4273
+ }
4274
+ const tscViolation = deploymentTopologySpreadConstraintsViolation(deployment, appLabel)
4275
+ if (tscViolation === null) {
4276
+ passFn(`${deployRel}: Deployment '${deployName}' має канонічні topologySpreadConstraints (k8s.mdc)`)
4277
+ } else {
4278
+ fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
4279
+ }
4280
+ validateHpaForDeployment(hpaDocs, deployName, isDevLike, `${deployRel}/${HPA_FILENAME}`, fail, passFn)
4281
+ validatePdbForDeployment(pdbDocs, deployName, appLabel, isDevLike, `${deployRel}/${PDB_FILENAME}`, fail, passFn)
4282
+ }
4283
+
4284
+ /**
4285
+ * Обробляє один каталог з Deployment: читає HPA/PDB і перевіряє кожен Deployment.
4286
+ * @param {Record<string, unknown>[]} deployments масив Deployment-документів
4287
+ * @param {string} dir абсолютний шлях до каталогу
4288
+ * @param {string} root корінь репозиторію
4289
+ * @param {(msg: string) => void} fail callback при помилці
4290
+ * @param {(msg: string) => void} passFn callback при успіху
4291
+ */
4292
+ async function validateDeploymentsInDir(deployments, dir, root, fail, passFn) {
4293
+ const relDir = relative(root, dir).replaceAll('\\', '/')
4294
+ const segment = k8sEnvSegmentFromRelPath(relDir + '/')
4295
+ const isDevLike = isDevLikeK8sEnvSegment(segment)
4296
+ const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
4297
+ const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
4298
+ const deployRel = relDir === '' ? '.' : relDir
4299
+ for (const deployment of deployments) {
4300
+ validateSingleDeploymentHpaPdbTopology(deployment, deployRel, isDevLike, hpaDocs, pdbDocs, fail, passFn)
4301
+ }
4302
+ }
4303
+
4304
+ /**
4305
+ * Витягує документи Deployment з YAML-файлу (повертає порожній масив, якщо файл недоступний або немає Deployment).
4306
+ * @param {string} filePath абсолютний шлях до YAML-файлу
4307
+ * @returns {Promise<Record<string, unknown>[]>} масив Deployment-документів
4308
+ */
4309
+ async function extractDeploymentsFromFile(filePath) {
4310
+ const raw = await tryReadFileUtf8(filePath)
4311
+ if (raw === undefined) return []
4312
+ const docs = tryParseAllYamlDocs(raw)
4313
+ if (docs === undefined) return []
4314
+ return collectDocsByKind(docs, 'Deployment')
4315
+ }
4316
+
3954
4317
  /**
3955
4318
  * Для кожного **Deployment** під `k8s/` перевіряє: у тому ж каталозі повинні бути
3956
4319
  * `hpa.yaml` (валідний `autoscaling/v2`) і `pdb.yaml` (валідний `policy/v1`), а сам Deployment
@@ -3966,98 +4329,11 @@ async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, pas
3966
4329
  const seenDirs = new Set()
3967
4330
  for (const abs of yamlFilesAbs) {
3968
4331
  const dir = dirname(abs)
3969
- if (seenDirs.has(dir)) continue
3970
- let raw
3971
- try {
3972
- raw = await readFile(abs, 'utf8')
3973
- } catch {
3974
- continue
3975
- }
3976
- let docs
3977
- try {
3978
- docs = parseAllDocuments(raw)
3979
- } catch {
3980
- continue
3981
- }
3982
- /** @type {Record<string, unknown>[]} */
3983
- const deployments = []
3984
- for (const doc of docs) {
3985
- if (doc.errors.length > 0) continue
3986
- const obj = doc.toJSON()
3987
- if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
3988
- const rec = /** @type {Record<string, unknown>} */ (obj)
3989
- if (rec.kind === 'Deployment') deployments.push(rec)
3990
- }
3991
- }
3992
- if (deployments.length === 0) continue
3993
- seenDirs.add(dir)
3994
- const relDir = relative(root, dir).replaceAll('\\', '/')
3995
- const segment = k8sEnvSegmentFromRelPath(relDir + '/')
3996
- const isDevLike = isDevLikeK8sEnvSegment(segment)
3997
- const hpaDocs = await readDocsByKindInDir(dir, 'HorizontalPodAutoscaler', HPA_FILENAME)
3998
- const pdbDocs = await readDocsByKindInDir(dir, 'PodDisruptionBudget', PDB_FILENAME)
3999
- for (const deployment of deployments) {
4000
- const deployName = manifestMetadataName(deployment)
4001
- const appLabel = deploymentAppLabel(deployment)
4002
- const deployRel = relDir === '' ? '.' : relDir
4003
- if (deployName === null) {
4004
- fail(`${deployRel}: Deployment без metadata.name — не можу перевірити HPA/PDB (k8s.mdc)`)
4005
- continue
4006
- }
4007
- if (appLabel === null) {
4008
- fail(`${deployRel}: Deployment '${deployName}' без spec.selector.matchLabels.app — додай мітку (k8s.mdc)`)
4009
- continue
4010
- }
4011
-
4012
- const tscViolation = deploymentTopologySpreadConstraintsViolation(deployment, appLabel)
4013
- if (tscViolation !== null) {
4014
- fail(`${deployRel}: Deployment '${deployName}': ${tscViolation}`)
4015
- } else {
4016
- passFn(`${deployRel}: Deployment '${deployName}' має канонічні topologySpreadConstraints (k8s.mdc)`)
4017
- }
4018
-
4019
- const hpaRel = `${deployRel}/${HPA_FILENAME}`
4020
- const matchedHpa = hpaDocs.find(h => {
4021
- const spec = h.spec
4022
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
4023
- const str = /** @type {Record<string, unknown>} */ (spec).scaleTargetRef
4024
- if (str === null || typeof str !== 'object' || Array.isArray(str)) return false
4025
- return /** @type {Record<string, unknown>} */ (str).name === deployName
4026
- })
4027
- if (matchedHpa === undefined) {
4028
- fail(
4029
- `${hpaRel}: відсутній або не знайдено HPA зі scaleTargetRef.name='${deployName}' поруч із Deployment (k8s.mdc)`
4030
- )
4031
- } else {
4032
- const hpaErrs = hpaManifestViolations(matchedHpa, deployName, isDevLike)
4033
- if (hpaErrs.length === 0) {
4034
- passFn(`${hpaRel}: HPA для Deployment '${deployName}' валідний (k8s.mdc)`)
4035
- } else {
4036
- for (const e of hpaErrs) fail(`${hpaRel}: ${e} (k8s.mdc)`)
4037
- }
4038
- }
4039
-
4040
- const pdbRel = `${deployRel}/${PDB_FILENAME}`
4041
- const matchedPdb = pdbDocs.find(p => {
4042
- const spec = p.spec
4043
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
4044
- const selector = /** @type {Record<string, unknown>} */ (spec).selector
4045
- if (selector === null || typeof selector !== 'object' || Array.isArray(selector)) return false
4046
- const ml = /** @type {Record<string, unknown>} */ (selector).matchLabels
4047
- if (ml === null || typeof ml !== 'object' || Array.isArray(ml)) return false
4048
- return /** @type {Record<string, unknown>} */ (ml).app === appLabel
4049
- })
4050
- if (matchedPdb === undefined) {
4051
- fail(
4052
- `${pdbRel}: відсутній або не знайдено PDB зі selector.matchLabels.app='${appLabel}' поруч із Deployment (k8s.mdc)`
4053
- )
4054
- } else {
4055
- const pdbErrs = pdbManifestViolations(matchedPdb, appLabel, isDevLike)
4056
- if (pdbErrs.length === 0) {
4057
- passFn(`${pdbRel}: PDB для Deployment '${deployName}' валідний (k8s.mdc)`)
4058
- } else {
4059
- for (const e of pdbErrs) fail(`${pdbRel}: ${e} (k8s.mdc)`)
4060
- }
4332
+ if (!seenDirs.has(dir)) {
4333
+ const deployments = await extractDeploymentsFromFile(abs)
4334
+ if (deployments.length > 0) {
4335
+ seenDirs.add(dir)
4336
+ await validateDeploymentsInDir(deployments, dir, root, fail, passFn)
4061
4337
  }
4062
4338
  }
4063
4339
  }