@nitra/cursor 1.8.123 → 1.8.125
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/abie.mdc +2 -2
- package/mdc/k8s.mdc +4 -0
- package/package.json +1 -1
- package/scripts/check-abie.mjs +41 -18
- package/scripts/check-k8s.mjs +412 -0
package/mdc/abie.mdc
CHANGED
|
@@ -4,7 +4,7 @@ alwaysApply: true
|
|
|
4
4
|
version: '1.15'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`file-link-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона
|
|
7
|
+
Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`file-link-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона тримати артефакти **Firebase Hosting** у **підкаталогах першого рівня** (безпосередні діти кореня репозиторію; у самому корені ці імена не вимагаються до видалення).
|
|
8
8
|
|
|
9
9
|
**`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
|
|
10
10
|
|
|
@@ -334,7 +334,7 @@ spec:
|
|
|
334
334
|
|
|
335
335
|
## Firebase Hosting
|
|
336
336
|
|
|
337
|
-
У корені
|
|
337
|
+
У **кожному** підкаталозі, що лежить **безпосередньо** в корені репозиторію, не тримати конфіг і кеш **Firebase Hosting**: у таких каталогах не повинно бути **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`** (у **самому** корені репозиторію ці імена перевіркою abie **не** розглядаються; `node_modules` / `.git` зі скану вилучаються).
|
|
338
338
|
|
|
339
339
|
## Git branches
|
|
340
340
|
|
package/mdc/k8s.mdc
CHANGED
|
@@ -294,6 +294,10 @@ data:
|
|
|
294
294
|
- **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
|
|
295
295
|
- **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
|
|
296
296
|
|
|
297
|
+
**Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
|
|
298
|
+
|
|
299
|
+
**Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
|
|
300
|
+
|
|
297
301
|
### Env-залежні межі (за сегментом після `/k8s/`)
|
|
298
302
|
|
|
299
303
|
**Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
|
package/package.json
CHANGED
package/scripts/check-abie.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* **`phpdocker-io/github-actions-delete-abandoned-branches`** у **`with.ignore_branches`** мають бути
|
|
9
9
|
* **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно).
|
|
10
10
|
*
|
|
11
|
-
* **Firebase Hosting:** у
|
|
11
|
+
* **Firebase Hosting:** у **підкаталогах першого рівня** (безпосередні діти кореня репозиторію; `node_modules` / `.git` пропускаються) не має бути
|
|
12
|
+
* **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`**; у **самому** корені репозиторію ці імена не перевіряються.
|
|
12
13
|
*
|
|
13
14
|
* **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
|
|
14
15
|
* має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
* у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — **`op: remove`** для **`/spec/clusterIP`**; якщо в base **явно** задано **`spec.clusterIPs`** — також **`remove`** для **`/spec/clusterIPs`** (інакше **API** може залишити **`None`** для **NodePort**; без ключа **`clusterIPs`** у base **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
|
|
39
40
|
*/
|
|
40
41
|
import { existsSync } from 'node:fs'
|
|
41
|
-
import { readFile } from 'node:fs/promises'
|
|
42
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
42
43
|
import { dirname, join, relative } from 'node:path'
|
|
43
44
|
|
|
44
45
|
import { parseAllDocuments } from 'yaml'
|
|
@@ -50,6 +51,9 @@ import { walkDir } from './utils/walkDir.mjs'
|
|
|
50
51
|
|
|
51
52
|
const CONFIG_FILE = '.n-cursor.json'
|
|
52
53
|
|
|
54
|
+
/** Каталоги-діти в корені, які пропускаються при скануванні на артефакти Firebase Hosting (abie). */
|
|
55
|
+
const ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES = new Set(['.git', 'node_modules'])
|
|
56
|
+
|
|
53
57
|
/** Маркер у kustomization.yaml: якщо зустрічається у файлі — для overlay ru у patch HTTPRoute потрібна анотація gwin…websocket. */
|
|
54
58
|
const HASURA_JWT_SECRET_IN_KUSTOMIZATION = 'HASURA_GRAPHQL_JWT_SECRET'
|
|
55
59
|
|
|
@@ -1679,27 +1683,46 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
|
|
|
1679
1683
|
}
|
|
1680
1684
|
|
|
1681
1685
|
/**
|
|
1682
|
-
* Перевіряє відсутність артефактів Firebase Hosting у
|
|
1686
|
+
* Перевіряє відсутність артефактів Firebase Hosting у **кожному** **підкаталозі першого рівня** від кореня
|
|
1687
|
+
* (не в самому корені репозиторію) — abie.mdc. Каталоги **`.git`** і **`node_modules`** у скануванні пропускаються.
|
|
1688
|
+
*
|
|
1683
1689
|
* @param {string} root корінь репозиторію
|
|
1684
1690
|
* @param {(msg: string) => void} passFn успішне повідомлення
|
|
1685
1691
|
* @param {(msg: string) => void} failFn повідомлення про порушення
|
|
1686
|
-
* @returns {void}
|
|
1687
|
-
*/
|
|
1688
|
-
function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1692
|
+
* @returns {Promise<void>}
|
|
1693
|
+
*/
|
|
1694
|
+
async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
|
|
1695
|
+
let entries
|
|
1696
|
+
try {
|
|
1697
|
+
entries = await readdir(root, { withFileTypes: true })
|
|
1698
|
+
} catch (error) {
|
|
1699
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1700
|
+
failFn(`Не вдалося прочитати ${root} для перевірки Firebase Hosting: ${msg} (abie.mdc)`)
|
|
1701
|
+
return
|
|
1702
|
+
}
|
|
1703
|
+
const topDirs = entries.filter(
|
|
1704
|
+
e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name)
|
|
1705
|
+
)
|
|
1706
|
+
let hasViolation = false
|
|
1707
|
+
for (const e of topDirs) {
|
|
1708
|
+
for (const name of ['.firebaserc', 'firebase.json']) {
|
|
1709
|
+
const rel = join(e.name, name).replaceAll('\\', '/')
|
|
1710
|
+
if (existsSync(join(root, e.name, name))) {
|
|
1711
|
+
failFn(`Знайдено заборонений файл Firebase Hosting: ${rel} — видали його (abie.mdc)`)
|
|
1712
|
+
hasViolation = true
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
if (existsSync(join(root, e.name, '.firebase'))) {
|
|
1716
|
+
failFn(`Знайдено заборонену директорію: ${e.name}/.firebase/ — видали її (abie.mdc)`)
|
|
1717
|
+
hasViolation = true
|
|
1695
1718
|
}
|
|
1696
1719
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
failFn('Знайдено директорію .firebase — видали її (abie.mdc)')
|
|
1700
|
-
} else {
|
|
1701
|
-
passFn('Немає .firebase/')
|
|
1720
|
+
if (hasViolation) {
|
|
1721
|
+
return
|
|
1702
1722
|
}
|
|
1723
|
+
passFn(
|
|
1724
|
+
'Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)'
|
|
1725
|
+
)
|
|
1703
1726
|
}
|
|
1704
1727
|
|
|
1705
1728
|
/**
|
|
@@ -2075,7 +2098,7 @@ export async function check() {
|
|
|
2075
2098
|
}
|
|
2076
2099
|
|
|
2077
2100
|
pass('Правило abie увімкнено — виконуємо перевірки')
|
|
2078
|
-
ensureNoFirebaseHostingArtifacts(root, pass, fail)
|
|
2101
|
+
await ensureNoFirebaseHostingArtifacts(root, pass, fail)
|
|
2079
2102
|
await checkCleanMergedBranch(root, pass, fail)
|
|
2080
2103
|
|
|
2081
2104
|
const yamlFiles = await findK8sYamlFiles(root)
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -79,6 +79,17 @@
|
|
|
79
79
|
* з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
|
|
80
80
|
* наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
|
|
81
81
|
* яке буде оцінено під час збірки Kustomize).
|
|
82
|
+
*
|
|
83
|
+
* **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
|
|
84
|
+
* `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`,
|
|
85
|
+
* `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
|
|
86
|
+
* помилка `check k8s` (k8s.mdc).
|
|
87
|
+
*
|
|
88
|
+
* **HPA / PDB тільки з Deployment у `base`:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
|
|
89
|
+
* дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
|
|
90
|
+
* (рекурсивно), якщо в цьому ж дереві немає `Deployment`. У `kustomization.yaml` overlay, який підключає
|
|
91
|
+
* каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB, поки в наслідуваному `base` у дереві
|
|
92
|
+
* не з’явиться `Deployment` (k8s.mdc).
|
|
82
93
|
*/
|
|
83
94
|
import { existsSync } from 'node:fs'
|
|
84
95
|
import { readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
@@ -386,6 +397,122 @@ function pathsFromKustomizationObject(obj) {
|
|
|
386
397
|
return out
|
|
387
398
|
}
|
|
388
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
|
|
402
|
+
* як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
|
|
403
|
+
* (рядки-шляхи) і **`replacements[].path`**, якщо задано.
|
|
404
|
+
* @param {unknown} obj корінь першого документа
|
|
405
|
+
* @returns {string[]}
|
|
406
|
+
*/
|
|
407
|
+
export function kustomizePathRefsForExistenceCheck(obj) {
|
|
408
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
409
|
+
return []
|
|
410
|
+
}
|
|
411
|
+
const fromPaths = pathsFromKustomizationObject(obj)
|
|
412
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
413
|
+
const pj = rec.patchesJson6902
|
|
414
|
+
if (Array.isArray(pj)) {
|
|
415
|
+
for (const item of pj) {
|
|
416
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
417
|
+
const pth = /** @type {Record<string, unknown>} */ (item).path
|
|
418
|
+
if (typeof pth === 'string' && pth.trim() !== '') {
|
|
419
|
+
fromPaths.push(pth.trim())
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const configurations = rec.configurations
|
|
425
|
+
if (Array.isArray(configurations)) {
|
|
426
|
+
for (const c of configurations) {
|
|
427
|
+
if (typeof c === 'string' && c.trim() !== '') {
|
|
428
|
+
fromPaths.push(c.trim())
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const replacements = rec.replacements
|
|
433
|
+
if (Array.isArray(replacements)) {
|
|
434
|
+
for (const r of replacements) {
|
|
435
|
+
if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
|
|
436
|
+
const pth = /** @type {Record<string, unknown>} */ (r).path
|
|
437
|
+
if (typeof pth === 'string' && pth.trim() !== '') {
|
|
438
|
+
fromPaths.push(pth.trim())
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return [...new Set(fromPaths)]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Перевіряє, що всі перелічені в `kustomization.yaml` локальні шляхи існують.
|
|
448
|
+
* @param {string} root корінь репо
|
|
449
|
+
* @param {string} kustAbs kustomization.yaml
|
|
450
|
+
* @param {string} rootNorm нормалізований корінь
|
|
451
|
+
* @param {(msg: string) => void} fail callback
|
|
452
|
+
* @returns {Promise<void>}
|
|
453
|
+
*/
|
|
454
|
+
async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
|
|
455
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
456
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
457
|
+
if (kust === null) {
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (kust.kind !== 'Kustomization') {
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
const refs = kustomizePathRefsForExistenceCheck(kust)
|
|
464
|
+
const kustDir = dirname(resolve(kustAbs))
|
|
465
|
+
for (const r of refs) {
|
|
466
|
+
if (typeof r !== 'string' || r.includes('://') || r.trim() === '') {
|
|
467
|
+
continue
|
|
468
|
+
}
|
|
469
|
+
const target = resolve(kustDir, r.trim())
|
|
470
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
|
|
471
|
+
fail(
|
|
472
|
+
`${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
|
|
473
|
+
}) (k8s.mdc)`
|
|
474
|
+
)
|
|
475
|
+
continue
|
|
476
|
+
}
|
|
477
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
478
|
+
let st
|
|
479
|
+
try {
|
|
480
|
+
st = await stat(target)
|
|
481
|
+
} catch {
|
|
482
|
+
st = undefined
|
|
483
|
+
}
|
|
484
|
+
if (st === undefined) {
|
|
485
|
+
fail(
|
|
486
|
+
`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
|
|
487
|
+
)
|
|
488
|
+
continue
|
|
489
|
+
}
|
|
490
|
+
if (st.isFile()) {
|
|
491
|
+
if (!YAML_EXTENSION_RE.test(target)) {
|
|
492
|
+
fail(
|
|
493
|
+
`${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
} else if (!st.isDirectory()) {
|
|
497
|
+
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
|
|
504
|
+
* @param {string} root
|
|
505
|
+
* @param {string[]} yamlFilesAbs
|
|
506
|
+
* @param {(msg: string) => void} fail
|
|
507
|
+
* @returns {Promise<void>}
|
|
508
|
+
*/
|
|
509
|
+
async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
|
|
510
|
+
const rootNorm = resolve(root)
|
|
511
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
512
|
+
await validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
389
516
|
/**
|
|
390
517
|
* Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
|
|
391
518
|
* (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
|
|
@@ -4107,6 +4234,287 @@ async function readFirstYamlObject(absPath) {
|
|
|
4107
4234
|
return null
|
|
4108
4235
|
}
|
|
4109
4236
|
|
|
4237
|
+
/**
|
|
4238
|
+
* Чи відносний шлях вказує на `k8s/…/base/kustomization.yaml` (каталог `base` у дереві k8s).
|
|
4239
|
+
* @param {string} rel POSIX-шлях
|
|
4240
|
+
* @returns {boolean} true, якщо батьківський каталог — `…/…/base` у шляху з `k8s`
|
|
4241
|
+
*/
|
|
4242
|
+
function isK8sBaseKustomizationRelPath(rel) {
|
|
4243
|
+
const n = rel.replaceAll('\\', '/')
|
|
4244
|
+
const d = dirname(n).replaceAll('\\', '/')
|
|
4245
|
+
if (basename(d) !== 'base') {
|
|
4246
|
+
return false
|
|
4247
|
+
}
|
|
4248
|
+
return d.startsWith('k8s/') || d.includes('/k8s/')
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
/**
|
|
4252
|
+
* Чи абсолютний шлях до каталогу — k8s-`base` (ідентифікуємо за тим, що `relative` від кореня
|
|
4253
|
+
* містить сегмент `k8s` і basename каталогу — `base`).
|
|
4254
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
4255
|
+
* @param {string} dirAbs абсолютний шлях до каталогу
|
|
4256
|
+
* @returns {boolean} true для `.../k8s/.../base` з `kustomization.yaml` у цьому каталозі
|
|
4257
|
+
*/
|
|
4258
|
+
function isUnderK8sPathRelToRoot(rootNorm, dirAbs) {
|
|
4259
|
+
const rel = (relative(rootNorm, dirAbs) || '.').replaceAll('\\', '/')
|
|
4260
|
+
if (rel === '' || rel === '.') {
|
|
4261
|
+
return false
|
|
4262
|
+
}
|
|
4263
|
+
if (rel.startsWith('../') || rel === '..') {
|
|
4264
|
+
return false
|
|
4265
|
+
}
|
|
4266
|
+
return rel === 'k8s' || rel.startsWith('k8s/') || rel.includes('/k8s/')
|
|
4267
|
+
}
|
|
4268
|
+
|
|
4269
|
+
/**
|
|
4270
|
+
* Чи файловий шлях усередині `dirAbs` (або збігається).
|
|
4271
|
+
* @param {string} dirAbs каталог
|
|
4272
|
+
* @param {string} fileAbs файл
|
|
4273
|
+
* @returns {boolean} true, якщо файл — піддерево каталогу
|
|
4274
|
+
*/
|
|
4275
|
+
function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
|
|
4276
|
+
const b = resolve(dirAbs)
|
|
4277
|
+
const f = resolve(fileAbs)
|
|
4278
|
+
const r = relative(b, f).replaceAll('\\', '/')
|
|
4279
|
+
if (r === '' || r === '.') {
|
|
4280
|
+
return true
|
|
4281
|
+
}
|
|
4282
|
+
return !r.startsWith('../') && r !== '..'
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
/**
|
|
4286
|
+
* За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
|
|
4287
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
4288
|
+
* @param {string[]} pathRefs тільки resources / bases / components / crds
|
|
4289
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
4290
|
+
* @returns {Promise<string[]>} абсолютні шляхи (без дедуплікації, якщо кілька однакових ref)
|
|
4291
|
+
*/
|
|
4292
|
+
async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm) {
|
|
4293
|
+
/** @type {string[]} */
|
|
4294
|
+
const out = []
|
|
4295
|
+
for (const ref of pathRefs) {
|
|
4296
|
+
if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
|
|
4297
|
+
continue
|
|
4298
|
+
}
|
|
4299
|
+
const resolved = resolve(kustDir, ref.trim())
|
|
4300
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
|
|
4301
|
+
continue
|
|
4302
|
+
}
|
|
4303
|
+
let st
|
|
4304
|
+
try {
|
|
4305
|
+
st = await stat(resolved)
|
|
4306
|
+
} catch {
|
|
4307
|
+
st = undefined
|
|
4308
|
+
}
|
|
4309
|
+
if (st === undefined || !st.isDirectory() || basename(resolved) !== 'base') {
|
|
4310
|
+
continue
|
|
4311
|
+
}
|
|
4312
|
+
if (!existsSync(join(resolved, 'kustomization.yaml')) || !isUnderK8sPathRelToRoot(rootNorm, resolved)) {
|
|
4313
|
+
continue
|
|
4314
|
+
}
|
|
4315
|
+
out.push(resolved)
|
|
4316
|
+
}
|
|
4317
|
+
return out
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
/**
|
|
4321
|
+
* Аналізує `resources` / `bases` / `components` / `crds` kustomization: чи в дереві є
|
|
4322
|
+
* `Deployment` / HPA / PDB.
|
|
4323
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4324
|
+
* @param {string} rootNorm корінь
|
|
4325
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці
|
|
4326
|
+
*/
|
|
4327
|
+
export async function kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm) {
|
|
4328
|
+
/** @type {Set<string>} */
|
|
4329
|
+
const visitedKustomization = new Set()
|
|
4330
|
+
const desc = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization)
|
|
4331
|
+
return {
|
|
4332
|
+
hasDeployment: desc.some(d => d.kind === 'Deployment'),
|
|
4333
|
+
hasHpa: desc.some(d => d.kind === 'HorizontalPodAutoscaler'),
|
|
4334
|
+
hasPdb: desc.some(d => d.kind === 'PodDisruptionBudget')
|
|
4335
|
+
}
|
|
4336
|
+
}
|
|
4337
|
+
|
|
4338
|
+
/**
|
|
4339
|
+
* Чи серед документів YAML-файлу є `HorizontalPodAutoscaler` або `PodDisruptionBudget`.
|
|
4340
|
+
* @param {string} fileAbs абсолютний шлях
|
|
4341
|
+
* @returns {Promise<boolean>} true, якщо такі kind знайдені
|
|
4342
|
+
*/
|
|
4343
|
+
async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
|
|
4344
|
+
const raw = await tryReadFileUtf8(fileAbs)
|
|
4345
|
+
if (raw === undefined) {
|
|
4346
|
+
return false
|
|
4347
|
+
}
|
|
4348
|
+
const docs = tryParseAllYamlDocs(raw)
|
|
4349
|
+
if (docs === undefined) {
|
|
4350
|
+
return false
|
|
4351
|
+
}
|
|
4352
|
+
for (const doc of docs) {
|
|
4353
|
+
if (doc.errors.length > 0) {
|
|
4354
|
+
continue
|
|
4355
|
+
}
|
|
4356
|
+
const o = doc.toJSON()
|
|
4357
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) {
|
|
4358
|
+
continue
|
|
4359
|
+
}
|
|
4360
|
+
const k = /** @type {Record<string, unknown>} */ (o).kind
|
|
4361
|
+
if (k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget') {
|
|
4362
|
+
return true
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
return false
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
/**
|
|
4369
|
+
* Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB дозволені в дереві kustomize лише разом із Deployment.
|
|
4370
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4371
|
+
* @param {string} rel для повідомлень
|
|
4372
|
+
* @param {(msg: string) => void} fail callback
|
|
4373
|
+
* @param {(msg: string) => void} passFn success
|
|
4374
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags мемоізований аналіз дерева
|
|
4375
|
+
* @returns {Promise<void>}
|
|
4376
|
+
*/
|
|
4377
|
+
async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags) {
|
|
4378
|
+
const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
|
|
4379
|
+
if (hasHpa || hasPdb) {
|
|
4380
|
+
if (hasDeployment) {
|
|
4381
|
+
passFn(
|
|
4382
|
+
`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
|
|
4383
|
+
)
|
|
4384
|
+
} else {
|
|
4385
|
+
fail(
|
|
4386
|
+
`${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
|
|
4387
|
+
)
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
}
|
|
4391
|
+
|
|
4392
|
+
/**
|
|
4393
|
+
* `kustomization` overlay, що посилається на `…/k8s/…/base`, не може додавати HPA / PDB як окремі YAML,
|
|
4394
|
+
* поки в наслідуваному base немає Deployment.
|
|
4395
|
+
* @param {string} root нормалізований корінь репо
|
|
4396
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4397
|
+
* @param {string} rel для повідомлень
|
|
4398
|
+
* @param {Record<string, unknown>} kustObj перший документ
|
|
4399
|
+
* @param {(msg: string) => void} fail callback
|
|
4400
|
+
* @param {(msg: string) => void} passFn success
|
|
4401
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
|
|
4402
|
+
* @returns {Promise<void>}
|
|
4403
|
+
*/
|
|
4404
|
+
async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4405
|
+
root,
|
|
4406
|
+
kustAbs,
|
|
4407
|
+
rel,
|
|
4408
|
+
kustObj,
|
|
4409
|
+
fail,
|
|
4410
|
+
passFn,
|
|
4411
|
+
getTreeFlags
|
|
4412
|
+
) {
|
|
4413
|
+
const kustDir = dirname(kustAbs)
|
|
4414
|
+
const pathRefs = resourcePathRefsFromKustomizationObject(kustObj)
|
|
4415
|
+
const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, root)
|
|
4416
|
+
if (baseDirs.length === 0) {
|
|
4417
|
+
return
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
const anyBaseHasDep = await (async () => {
|
|
4421
|
+
for (const baseDir of baseDirs) {
|
|
4422
|
+
const { hasDeployment: h } = await getTreeFlags(join(baseDir, 'kustomization.yaml'))
|
|
4423
|
+
if (h) {
|
|
4424
|
+
return true
|
|
4425
|
+
}
|
|
4426
|
+
}
|
|
4427
|
+
return false
|
|
4428
|
+
})()
|
|
4429
|
+
for (const ref of pathRefs) {
|
|
4430
|
+
if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
|
|
4431
|
+
continue
|
|
4432
|
+
}
|
|
4433
|
+
const fAbs = resolve(kustDir, ref.trim())
|
|
4434
|
+
if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) {
|
|
4435
|
+
continue
|
|
4436
|
+
}
|
|
4437
|
+
let st
|
|
4438
|
+
try {
|
|
4439
|
+
st = await stat(fAbs)
|
|
4440
|
+
} catch {
|
|
4441
|
+
st = undefined
|
|
4442
|
+
}
|
|
4443
|
+
if (st === undefined) {
|
|
4444
|
+
continue
|
|
4445
|
+
}
|
|
4446
|
+
if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) {
|
|
4447
|
+
continue
|
|
4448
|
+
}
|
|
4449
|
+
const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
|
|
4450
|
+
if (fUnderSomeBase) {
|
|
4451
|
+
continue
|
|
4452
|
+
}
|
|
4453
|
+
const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
|
|
4454
|
+
if (!hpaPdb) {
|
|
4455
|
+
continue
|
|
4456
|
+
}
|
|
4457
|
+
if (!anyBaseHasDep) {
|
|
4458
|
+
fail(
|
|
4459
|
+
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4460
|
+
)
|
|
4461
|
+
} else {
|
|
4462
|
+
passFn(
|
|
4463
|
+
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4464
|
+
)
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
/**
|
|
4470
|
+
* Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
|
|
4471
|
+
* посилається на base, не додає HPA/PDB без Deployment у base.
|
|
4472
|
+
* @param {string} root корінь репо
|
|
4473
|
+
* @param {string[]} yamlFilesAbs yaml у k8s
|
|
4474
|
+
* @param {(msg: string) => void} fail callback
|
|
4475
|
+
* @param {(msg: string) => void} passFn pass
|
|
4476
|
+
* @returns {Promise<void>}
|
|
4477
|
+
*/
|
|
4478
|
+
async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs, fail, passFn) {
|
|
4479
|
+
const rootNorm = resolve(root)
|
|
4480
|
+
/** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
|
|
4481
|
+
const treeFlagsMemo = new Map()
|
|
4482
|
+
/**
|
|
4483
|
+
* @param {string} kustPath
|
|
4484
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
|
|
4485
|
+
*/
|
|
4486
|
+
const getTreeFlags = kustPath => {
|
|
4487
|
+
const k = resolve(kustPath)
|
|
4488
|
+
let p = treeFlagsMemo.get(k)
|
|
4489
|
+
if (p === undefined) {
|
|
4490
|
+
p = kustomizeResourceTreeHpaPdbDeploymentFlags(k, rootNorm)
|
|
4491
|
+
treeFlagsMemo.set(k, p)
|
|
4492
|
+
}
|
|
4493
|
+
return p
|
|
4494
|
+
}
|
|
4495
|
+
const kustFiles = yamlFilesAbs.filter(abs => basename(abs).toLowerCase() === 'kustomization.yaml')
|
|
4496
|
+
for (const kustAbs of kustFiles) {
|
|
4497
|
+
const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
4498
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
4499
|
+
if (kust === null) {
|
|
4500
|
+
continue
|
|
4501
|
+
}
|
|
4502
|
+
if (isK8sBaseKustomizationRelPath(rel)) {
|
|
4503
|
+
await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
|
|
4504
|
+
} else {
|
|
4505
|
+
await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4506
|
+
rootNorm,
|
|
4507
|
+
kustAbs,
|
|
4508
|
+
rel,
|
|
4509
|
+
kust,
|
|
4510
|
+
fail,
|
|
4511
|
+
passFn,
|
|
4512
|
+
getTreeFlags
|
|
4513
|
+
)
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4110
4518
|
/**
|
|
4111
4519
|
* Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
|
|
4112
4520
|
* @param {Record<string, unknown>} kust об'єкт kustomization
|
|
@@ -4376,8 +4784,12 @@ export async function check() {
|
|
|
4376
4784
|
|
|
4377
4785
|
await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
|
|
4378
4786
|
|
|
4787
|
+
await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
|
|
4788
|
+
|
|
4379
4789
|
await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
|
|
4380
4790
|
|
|
4791
|
+
await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
|
|
4792
|
+
|
|
4381
4793
|
await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
|
|
4382
4794
|
|
|
4383
4795
|
await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
|