@nitra/cursor 1.8.103 → 1.8.105
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/bin/auto-rules.md +45 -0
- package/bin/n-cursor.js +280 -149
- package/mdc/graphql.mdc +15 -1
- package/package.json +1 -1
- package/schemas/n-cursor.json +16 -0
- package/scripts/auto-rules.mjs +404 -0
- package/scripts/check-abie.mjs +54 -36
- package/scripts/check-bun.mjs +2 -6
- package/scripts/check-graphql.mjs +112 -34
- package/scripts/check-js-lint.mjs +15 -11
- package/scripts/check-k8s.mjs +1652 -627
- package/scripts/check-nginx-default-tpl.mjs +17 -10
- package/scripts/check-npm-module.mjs +3 -3
- package/scripts/check-text.mjs +1 -3
- package/scripts/check-vue.mjs +2 -2
- package/scripts/utils/docker-hadolint.mjs +9 -5
- package/scripts/utils/gha-workflow.mjs +90 -72
- package/scripts/utils/workspaces.mjs +39 -16
package/scripts/check-k8s.mjs
CHANGED
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
* **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
|
|
46
46
|
* на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
|
|
47
47
|
*
|
|
48
|
+
* **Мішень patch:** у **`patches[].target`** і **`patchesJson6902[].target`** (без **labelSelector** / **annotationSelector**)
|
|
49
|
+
* має існувати відповідний ресурс у зібраному з **`resources`**, **`bases`**, **`components`**, **`crds`** каталозі (рекурсивно для підкаталогів з **`kustomization.yaml`**).
|
|
50
|
+
* Для **`patchesStrategicMerge`** і для **`patches[].path`** без **`target`** і без inline **`patch`** (зовнішній strategic-merge)
|
|
51
|
+
* кожен YAML-документ з кореневим **`kind`** і **`metadata.name`** також звіряється з цим каталогом.
|
|
52
|
+
*
|
|
48
53
|
* Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
|
|
49
54
|
* **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
|
|
50
55
|
* компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
|
|
@@ -137,15 +142,38 @@ const EXPLICIT_K8S_SCHEMAS = new Map([
|
|
|
137
142
|
]
|
|
138
143
|
])
|
|
139
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Прибирає зовнішні лапки зі скаляра YAML (`"x"` / `'x'`), якщо вони парні.
|
|
147
|
+
* @param {string | undefined} raw значення з `match(…)[1]` або подібне
|
|
148
|
+
* @returns {string | undefined} рядок без лапок або undefined, якщо вхід undefined
|
|
149
|
+
*/
|
|
150
|
+
function trimYamlScalarQuotes(raw) {
|
|
151
|
+
if (raw === undefined) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
const s = String(raw)
|
|
155
|
+
if (s.length >= 2 && ((s[0] === '"' && s.at(-1) === '"') || (s[0] === "'" && s.at(-1) === "'"))) {
|
|
156
|
+
return s.slice(1, -1)
|
|
157
|
+
}
|
|
158
|
+
return s
|
|
159
|
+
}
|
|
160
|
+
|
|
140
161
|
/**
|
|
141
162
|
* Витягує кореневе поле **`type:`** з документа (без повного YAML-парсера).
|
|
142
163
|
* @param {string} doc фрагмент YAML одного документа
|
|
143
164
|
* @returns {string | undefined} значення без лапок або undefined, якщо поля немає
|
|
144
165
|
*/
|
|
145
166
|
function extractTopLevelManifestType(doc) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
167
|
+
for (const line of doc.split(YAML_LINE_SPLIT_RE)) {
|
|
168
|
+
const m = line.match(TYPE_FIELD_RE)
|
|
169
|
+
if (m) {
|
|
170
|
+
const raw = trimYamlScalarQuotes(m[1])
|
|
171
|
+
if (raw === undefined || raw === '') {
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
return raw
|
|
175
|
+
}
|
|
176
|
+
}
|
|
149
177
|
}
|
|
150
178
|
|
|
151
179
|
/**
|
|
@@ -193,6 +221,22 @@ const YANNH_GROUPS = new Set([
|
|
|
193
221
|
])
|
|
194
222
|
|
|
195
223
|
const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
|
|
224
|
+
const PATH_SPLIT_RE = /[/\\]/u
|
|
225
|
+
const YAML_EXTENSION_RE = /\.ya?ml$/iu
|
|
226
|
+
const YAML_LINE_SPLIT_RE = /\r?\n/u
|
|
227
|
+
const API_VERSION_FIELD_RE = /^\s*apiVersion:\s*(\S+)\s*$/
|
|
228
|
+
const KIND_FIELD_RE = /^\s*kind:\s*(\S+)\s*$/
|
|
229
|
+
const TYPE_FIELD_RE = /^\s*type:\s*(\S+)\s*$/
|
|
230
|
+
const YAML_DOC_SEPARATOR_LINE_RE = /^---\s*$/
|
|
231
|
+
const HEALTHCHECK_DELETE_RE = /\$patch:\s*delete/u
|
|
232
|
+
const HEALTHCHECK_KIND_RE = /kind:\s*HealthCheckPolicy/u
|
|
233
|
+
const METADATA_LINE_RE = /metadata:/u
|
|
234
|
+
const NAME_NON_EMPTY_RE = /name:\s*\S+/u
|
|
235
|
+
const K8S_BASE_KUSTOMIZATION_PATH_RE = /(^|\/)k8s\/base\/kustomization\.yaml$/u
|
|
236
|
+
const K8S_BASE_SEGMENT_RE = /(^|\/)k8s\/base\//u
|
|
237
|
+
const OXLINT_SCHEMA_MODELINE_RE = /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u
|
|
238
|
+
const HTTPS_SCHEMA_RE = /^https:/iu
|
|
239
|
+
const HASURA_GRAPHQL_ENGINE_RE = /(^|\/)hasura\/graphql-engine(?::|$)/u
|
|
196
240
|
|
|
197
241
|
/**
|
|
198
242
|
* Чи містить шлях сегмент директорії `k8s` (рівно ця назва компонента).
|
|
@@ -200,7 +244,7 @@ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
|
|
|
200
244
|
* @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
|
|
201
245
|
*/
|
|
202
246
|
export function pathHasK8sSegment(filePath) {
|
|
203
|
-
const parts = filePath.split(
|
|
247
|
+
const parts = filePath.split(PATH_SPLIT_RE)
|
|
204
248
|
return parts.includes('k8s')
|
|
205
249
|
}
|
|
206
250
|
|
|
@@ -342,6 +386,45 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
|
|
|
342
386
|
return null
|
|
343
387
|
}
|
|
344
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Один файл **`kustomization.yaml`**: **`svc.yaml`** у шляхах має мати парний **`svc-hl.yaml`**.
|
|
391
|
+
* @param {string} root корінь репозиторію
|
|
392
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
393
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
394
|
+
* @returns {Promise<void>}
|
|
395
|
+
*/
|
|
396
|
+
async function validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail) {
|
|
397
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
398
|
+
let raw
|
|
399
|
+
try {
|
|
400
|
+
raw = await readFile(kustAbs, 'utf8')
|
|
401
|
+
} catch (error) {
|
|
402
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
403
|
+
fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
const lines = toLines(raw)
|
|
407
|
+
const body = yamlBodyAfterModeline(lines)
|
|
408
|
+
/** @type {import('yaml').Document[] | undefined} */
|
|
409
|
+
let docs
|
|
410
|
+
try {
|
|
411
|
+
docs = parseAllDocuments(body)
|
|
412
|
+
} catch {
|
|
413
|
+
fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
const first = docs[0]?.toJSON()
|
|
417
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const pathRefs = pathsFromKustomizationObject(first)
|
|
421
|
+
const kustDir = dirname(kustAbs)
|
|
422
|
+
const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
|
|
423
|
+
if (v !== null) {
|
|
424
|
+
fail(`${rel}: ${v}`)
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
345
428
|
/**
|
|
346
429
|
* Перевіряє всі **`kustomization.yaml`** під **`k8s`**: разом із **`svc.yaml`** має бути **`svc-hl.yaml`** у полях шляхів.
|
|
347
430
|
* @param {string} root корінь репозиторію
|
|
@@ -350,39 +433,8 @@ export function kustomizationSvcYamlMissingSvcHlViolation(kustomizationDir, path
|
|
|
350
433
|
* @returns {Promise<void>}
|
|
351
434
|
*/
|
|
352
435
|
async function validateKustomizationIncludesSvcHlWithSvc(root, yamlFiles, fail) {
|
|
353
|
-
for (const kustAbs of yamlFiles) {
|
|
354
|
-
|
|
355
|
-
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
356
|
-
let raw
|
|
357
|
-
try {
|
|
358
|
-
raw = await readFile(kustAbs, 'utf8')
|
|
359
|
-
} catch (error) {
|
|
360
|
-
const msg = error instanceof Error ? error.message : String(error)
|
|
361
|
-
fail(`${rel}: не вдалося прочитати для перевірки svc.yaml/svc-hl.yaml у kustomization (${msg})`)
|
|
362
|
-
}
|
|
363
|
-
if (raw !== undefined) {
|
|
364
|
-
const lines = toLines(raw)
|
|
365
|
-
const body = yamlBodyAfterModeline(lines)
|
|
366
|
-
/** @type {import('yaml').Document[] | undefined} */
|
|
367
|
-
let docs
|
|
368
|
-
try {
|
|
369
|
-
docs = parseAllDocuments(body)
|
|
370
|
-
} catch {
|
|
371
|
-
fail(`${rel}: не вдалося розпарсити YAML для перевірки svc.yaml/svc-hl.yaml у kustomization (див. k8s.mdc)`)
|
|
372
|
-
}
|
|
373
|
-
if (docs !== undefined) {
|
|
374
|
-
const first = docs[0]?.toJSON()
|
|
375
|
-
if (first !== null && first !== undefined && typeof first === 'object' && !Array.isArray(first)) {
|
|
376
|
-
const pathRefs = pathsFromKustomizationObject(first)
|
|
377
|
-
const kustDir = dirname(kustAbs)
|
|
378
|
-
const v = kustomizationSvcYamlMissingSvcHlViolation(kustDir, pathRefs)
|
|
379
|
-
if (v !== null) {
|
|
380
|
-
fail(`${rel}: ${v}`)
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
}
|
|
436
|
+
for (const kustAbs of yamlFiles.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
437
|
+
await validateOneKustomizationSvcHlWithSvc(root, kustAbs, fail)
|
|
386
438
|
}
|
|
387
439
|
}
|
|
388
440
|
|
|
@@ -436,32 +488,45 @@ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
|
|
|
436
488
|
const kustDir = dirname(normKust)
|
|
437
489
|
const pathRefs = pathsFromKustomizationObject(first)
|
|
438
490
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
491
|
+
/**
|
|
492
|
+
* @param {string} ref шлях з kustomization
|
|
493
|
+
* @returns {Promise<void>}
|
|
494
|
+
*/
|
|
495
|
+
async function handleKustomizeManagedPathRef(ref) {
|
|
496
|
+
if (ref.includes('://')) {
|
|
497
|
+
return
|
|
498
|
+
}
|
|
499
|
+
const resolved = resolve(kustDir, ref)
|
|
500
|
+
let st
|
|
501
|
+
try {
|
|
502
|
+
st = await stat(resolved)
|
|
503
|
+
} catch {
|
|
504
|
+
st = undefined
|
|
505
|
+
}
|
|
506
|
+
if (!st) {
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
if (st.isFile()) {
|
|
510
|
+
if (YAML_EXTENSION_RE.test(resolved)) {
|
|
511
|
+
const pr = posixRelFromAbs(root, resolved)
|
|
512
|
+
if (pr !== null) {
|
|
513
|
+
managed.add(pr)
|
|
461
514
|
}
|
|
462
515
|
}
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
if (!st.isDirectory()) {
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
|
|
522
|
+
if (childK !== null) {
|
|
523
|
+
await walkKustomization(childK)
|
|
463
524
|
}
|
|
464
525
|
}
|
|
526
|
+
|
|
527
|
+
for (const ref of pathRefs) {
|
|
528
|
+
await handleKustomizeManagedPathRef(ref)
|
|
529
|
+
}
|
|
465
530
|
}
|
|
466
531
|
|
|
467
532
|
for (const k of kustomizationAbsList) {
|
|
@@ -472,235 +537,916 @@ export async function collectKustomizeManagedRelPaths(root, yamlFilesAbs) {
|
|
|
472
537
|
}
|
|
473
538
|
|
|
474
539
|
/**
|
|
475
|
-
*
|
|
476
|
-
* @param {
|
|
477
|
-
* @returns {
|
|
540
|
+
* Шляхи лише з полів ресурсів Kustomization (**без** patch-файлів).
|
|
541
|
+
* @param {unknown} obj корінь першого документа Kustomization
|
|
542
|
+
* @returns {string[]} відносні посилання
|
|
478
543
|
*/
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
544
|
+
function resourcePathRefsFromKustomizationObject(obj) {
|
|
545
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
|
|
546
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
547
|
+
/** @type {string[]} */
|
|
548
|
+
const out = []
|
|
549
|
+
pushStringPaths(rec.resources, out)
|
|
550
|
+
pushStringPaths(rec.bases, out)
|
|
551
|
+
pushStringPaths(rec.components, out)
|
|
552
|
+
pushStringPaths(rec.crds, out)
|
|
553
|
+
return out
|
|
482
554
|
}
|
|
483
555
|
|
|
484
556
|
/**
|
|
485
|
-
*
|
|
486
|
-
* @
|
|
487
|
-
* @returns {string | null} текст порушення або null, якщо ок
|
|
557
|
+
* Дескриптор ресурсу для звірки з **`target`** Kustomize / strategic-merge фрагментом.
|
|
558
|
+
* @typedef {{ group: string, version: string, kind: string, name: string, namespace: string }} KustomizeResourceDescriptor
|
|
488
559
|
*/
|
|
489
|
-
export function baseKustomizationNamespaceViolation(obj) {
|
|
490
|
-
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
491
|
-
return 'у base/kustomization.yaml завжди має бути непорожній namespace: (див. k8s.mdc)'
|
|
492
|
-
}
|
|
493
|
-
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
494
|
-
const ns = rec.namespace
|
|
495
|
-
if (typeof ns === 'string' && ns.trim() !== '') {
|
|
496
|
-
return null
|
|
497
|
-
}
|
|
498
|
-
return 'у base/kustomization.yaml завжди додай непорожній namespace: (наприклад namespace: dev; див. k8s.mdc)'
|
|
499
|
-
}
|
|
500
560
|
|
|
501
561
|
/**
|
|
502
|
-
*
|
|
503
|
-
* @param {
|
|
504
|
-
* @returns {
|
|
562
|
+
* Розбиває **`apiVersion`** Kubernetes на **group** і **version**.
|
|
563
|
+
* @param {unknown} apiVersion значення з YAML
|
|
564
|
+
* @returns {{ group: string, version: string }} для `group/version` — два сегменти; для `v1` — core (**group** порожній).
|
|
505
565
|
*/
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
566
|
+
export function splitK8sApiVersion(apiVersion) {
|
|
567
|
+
if (typeof apiVersion !== 'string') {
|
|
568
|
+
return { group: '', version: '' }
|
|
569
|
+
}
|
|
570
|
+
const t = apiVersion.trim()
|
|
571
|
+
if (t === '') {
|
|
572
|
+
return { group: '', version: '' }
|
|
573
|
+
}
|
|
574
|
+
const i = t.indexOf('/')
|
|
575
|
+
if (i === -1) {
|
|
576
|
+
return { group: '', version: t }
|
|
577
|
+
}
|
|
578
|
+
return { group: t.slice(0, i), version: t.slice(i + 1) }
|
|
516
579
|
}
|
|
517
580
|
|
|
518
581
|
/**
|
|
519
|
-
*
|
|
520
|
-
* @param {string
|
|
521
|
-
* @returns {
|
|
582
|
+
* Чи patch-**target** використовує **labelSelector** / **annotationSelector** (тоді статична перевірка за іменем не застосовується).
|
|
583
|
+
* @param {Record<string, unknown>} t об’єкт **target**
|
|
584
|
+
* @returns {boolean} true, якщо є непорожній селектор
|
|
522
585
|
*/
|
|
523
|
-
function
|
|
524
|
-
|
|
525
|
-
|
|
586
|
+
function patchTargetUsesSelector(t) {
|
|
587
|
+
const ls = t.labelSelector
|
|
588
|
+
if (
|
|
589
|
+
ls !== undefined &&
|
|
590
|
+
ls !== null &&
|
|
591
|
+
ls !== '' &&
|
|
592
|
+
((typeof ls === 'object' && !Array.isArray(ls) && Object.keys(ls).length > 0) ||
|
|
593
|
+
(typeof ls === 'string' && ls.trim() !== ''))
|
|
594
|
+
) {
|
|
595
|
+
return true
|
|
526
596
|
}
|
|
527
|
-
|
|
597
|
+
const asel = t.annotationSelector
|
|
598
|
+
if (
|
|
599
|
+
asel !== undefined &&
|
|
600
|
+
asel !== null &&
|
|
601
|
+
asel !== '' &&
|
|
602
|
+
((typeof asel === 'object' && !Array.isArray(asel) && Object.keys(asel).length > 0) ||
|
|
603
|
+
(typeof asel === 'string' && asel.trim() !== ''))
|
|
604
|
+
) {
|
|
605
|
+
return true
|
|
606
|
+
}
|
|
607
|
+
return false
|
|
528
608
|
}
|
|
529
609
|
|
|
530
610
|
/**
|
|
531
|
-
* Чи
|
|
532
|
-
* @param {
|
|
533
|
-
* @returns {
|
|
611
|
+
* Чи варто перевіряти **target** на наявність ресурсу в каталозі (є **kind** і **name**, немає селекторів).
|
|
612
|
+
* @param {unknown} target значення **patches[].target**
|
|
613
|
+
* @returns {boolean} true, якщо перевірка доречна
|
|
534
614
|
*/
|
|
535
|
-
export function
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
try {
|
|
539
|
-
docs = parseAllDocuments(body)
|
|
540
|
-
} catch {
|
|
541
|
-
return 'unparsed'
|
|
615
|
+
export function shouldValidateKustomizePatchTarget(target) {
|
|
616
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
|
617
|
+
return false
|
|
542
618
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const obj = doc.toJSON()
|
|
549
|
-
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
550
|
-
const kind = obj.kind
|
|
551
|
-
if (kind === 'BackendConfig') {
|
|
552
|
-
hasBc = true
|
|
553
|
-
} else if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
|
|
554
|
-
hasOther = true
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
}
|
|
619
|
+
const t = /** @type {Record<string, unknown>} */ (target)
|
|
620
|
+
const kind = t.kind
|
|
621
|
+
const name = t.name
|
|
622
|
+
if (typeof kind !== 'string' || kind.trim() === '' || typeof name !== 'string' || name.trim() === '') {
|
|
623
|
+
return false
|
|
558
624
|
}
|
|
559
|
-
|
|
560
|
-
if (!hasBc) return 'none'
|
|
561
|
-
if (hasOther) return 'mixed'
|
|
562
|
-
return 'only'
|
|
625
|
+
return !patchTargetUsesSelector(t)
|
|
563
626
|
}
|
|
564
627
|
|
|
565
628
|
/**
|
|
566
|
-
*
|
|
567
|
-
* @param {
|
|
568
|
-
* @param {
|
|
569
|
-
* @
|
|
570
|
-
* @returns {Promise<void>}
|
|
629
|
+
* Чи **target** Kustomize відповідає дескриптору ресурсу (узгоджено з правилами відбору Kustomize: пропущені поля **target** не звужують).
|
|
630
|
+
* @param {unknown} target об’єкт **target**
|
|
631
|
+
* @param {KustomizeResourceDescriptor} res дескриптор з інвентарю
|
|
632
|
+
* @returns {boolean} true, якщо збігається
|
|
571
633
|
*/
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (bcPresence === 'mixed') {
|
|
583
|
-
fail(
|
|
584
|
-
`${rel}: у файлі разом BackendConfig та інші kind — винеси BackendConfig окремо або прибери вручну; автоматичне видалення не застосовується (див. k8s.mdc)`
|
|
585
|
-
)
|
|
586
|
-
} else if (bcPresence === 'only') {
|
|
587
|
-
try {
|
|
588
|
-
await unlink(abs)
|
|
589
|
-
pass(`${rel}: видалено (лише kind: BackendConfig; див. k8s.mdc)`)
|
|
590
|
-
} catch (error) {
|
|
591
|
-
fail(`${rel}: не вдалося видалити BackendConfig-файл (${error.message})`)
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
} catch (error) {
|
|
595
|
-
fail(`${rel}: не вдалося прочитати для перевірки BackendConfig (${error.message})`)
|
|
596
|
-
}
|
|
634
|
+
export function kustomizePatchTargetMatchesDescriptor(target, res) {
|
|
635
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
|
636
|
+
return false
|
|
637
|
+
}
|
|
638
|
+
const rec = /** @type {Record<string, unknown>} */ (target)
|
|
639
|
+
const tk = rec.kind
|
|
640
|
+
const tn = rec.name
|
|
641
|
+
if (typeof tk !== 'string' || typeof tn !== 'string') {
|
|
642
|
+
return false
|
|
597
643
|
}
|
|
644
|
+
if (tk.trim() !== res.kind || tn.trim() !== res.name) {
|
|
645
|
+
return false
|
|
646
|
+
}
|
|
647
|
+
const tgtGroup = rec.group
|
|
648
|
+
if (typeof tgtGroup === 'string' && tgtGroup.trim() !== '' && res.group !== tgtGroup.trim()) {
|
|
649
|
+
return false
|
|
650
|
+
}
|
|
651
|
+
const tgtVersion = rec.version
|
|
652
|
+
if (typeof tgtVersion === 'string' && tgtVersion.trim() !== '' && res.version !== tgtVersion.trim()) {
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
const tgtNs = rec.namespace
|
|
656
|
+
if (typeof tgtNs === 'string' && tgtNs.trim() !== '' && res.namespace !== tgtNs.trim()) {
|
|
657
|
+
return false
|
|
658
|
+
}
|
|
659
|
+
return true
|
|
598
660
|
}
|
|
599
661
|
|
|
600
662
|
/**
|
|
601
|
-
*
|
|
602
|
-
* @param {
|
|
603
|
-
* @
|
|
663
|
+
* Чи є в каталозі ресурс, який задовольняє **target**.
|
|
664
|
+
* @param {KustomizeResourceDescriptor[]} catalog зібрані дескриптори
|
|
665
|
+
* @param {unknown} target об’єкт **target**
|
|
666
|
+
* @returns {boolean} true, якщо перевірка не потрібна або знайдено збіг
|
|
604
667
|
*/
|
|
605
|
-
function
|
|
606
|
-
|
|
607
|
-
|
|
668
|
+
export function kustomizeResourceCatalogMatchesPatchTarget(catalog, target) {
|
|
669
|
+
if (!shouldValidateKustomizePatchTarget(target)) {
|
|
670
|
+
return true
|
|
671
|
+
}
|
|
672
|
+
return catalog.some(res => kustomizePatchTargetMatchesDescriptor(target, res))
|
|
608
673
|
}
|
|
609
674
|
|
|
610
675
|
/**
|
|
611
|
-
*
|
|
612
|
-
* @param {
|
|
613
|
-
* @
|
|
676
|
+
* Чи два дескриптори повністю збігаються (для strategic-merge фрагмента).
|
|
677
|
+
* @param {KustomizeResourceDescriptor} a перший
|
|
678
|
+
* @param {KustomizeResourceDescriptor} b другий
|
|
679
|
+
* @returns {boolean} true, якщо всі поля однакові
|
|
614
680
|
*/
|
|
615
|
-
function
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
681
|
+
export function kustomizeResourceDescriptorsIdentityEqual(a, b) {
|
|
682
|
+
return (
|
|
683
|
+
a.group === b.group &&
|
|
684
|
+
a.version === b.version &&
|
|
685
|
+
a.kind === b.kind &&
|
|
686
|
+
a.name === b.name &&
|
|
687
|
+
a.namespace === b.namespace
|
|
688
|
+
)
|
|
619
689
|
}
|
|
620
690
|
|
|
621
691
|
/**
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
* @
|
|
625
|
-
* @returns {Promise<string>} тіло для `parseAllDocuments`
|
|
692
|
+
* Непорожнє **`metadata.name`**, якщо задано коректно.
|
|
693
|
+
* @param {unknown} meta значення **metadata**
|
|
694
|
+
* @returns {string} ім’я або порожній рядок
|
|
626
695
|
*/
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
|
|
631
|
-
return yamlBodyAfterModeline(lines)
|
|
696
|
+
function metadataNameTrimmed(meta) {
|
|
697
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
698
|
+
return ''
|
|
632
699
|
}
|
|
633
|
-
|
|
700
|
+
const n = /** @type {Record<string, unknown>} */ (meta).name
|
|
701
|
+
return typeof n === 'string' && n.trim() !== '' ? n.trim() : ''
|
|
634
702
|
}
|
|
635
703
|
|
|
636
704
|
/**
|
|
637
|
-
*
|
|
638
|
-
* @param {
|
|
639
|
-
* @returns {
|
|
705
|
+
* Непорожній **`metadata.namespace`**, якщо задано коректно.
|
|
706
|
+
* @param {unknown} meta значення **metadata**
|
|
707
|
+
* @returns {string} namespace або порожній рядок
|
|
640
708
|
*/
|
|
641
|
-
function
|
|
642
|
-
|
|
643
|
-
return
|
|
644
|
-
.filter(d => d.errors.length === 0)
|
|
645
|
-
.map(d => d.toJSON())
|
|
646
|
-
.filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
|
|
647
|
-
} catch {
|
|
648
|
-
return []
|
|
709
|
+
function metadataNamespaceTrimmed(meta) {
|
|
710
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
711
|
+
return ''
|
|
649
712
|
}
|
|
713
|
+
const ns = /** @type {Record<string, unknown>} */ (meta).namespace
|
|
714
|
+
return typeof ns === 'string' && ns.trim() !== '' ? ns.trim() : ''
|
|
650
715
|
}
|
|
651
716
|
|
|
652
717
|
/**
|
|
653
|
-
*
|
|
654
|
-
* @param {string}
|
|
655
|
-
* @
|
|
718
|
+
* Будує дескриптор з маніфесту (пропускає **Kustomization** та об’єкти без **metadata.name**).
|
|
719
|
+
* @param {Record<string, unknown>} obj корінь документа
|
|
720
|
+
* @param {string} kustomizationDefaultNs значення **`namespace:`** з kustomization, що підключив файл
|
|
721
|
+
* @returns {KustomizeResourceDescriptor | null} дескриптор для звірки або **null**, якщо документ не підходить.
|
|
656
722
|
*/
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
723
|
+
export function kustomizeResourceDescriptorFromManifest(obj, kustomizationDefaultNs) {
|
|
724
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
725
|
+
return null
|
|
726
|
+
}
|
|
727
|
+
const kindRaw = obj.kind
|
|
728
|
+
if (typeof kindRaw !== 'string' || kindRaw.trim() === '') {
|
|
729
|
+
return null
|
|
730
|
+
}
|
|
731
|
+
const kind = kindRaw.trim()
|
|
732
|
+
if (kind === 'Kustomization') {
|
|
733
|
+
return null
|
|
734
|
+
}
|
|
735
|
+
const meta = obj.metadata
|
|
736
|
+
const name = metadataNameTrimmed(meta)
|
|
737
|
+
if (name === '') {
|
|
738
|
+
return null
|
|
739
|
+
}
|
|
740
|
+
const { group, version } = splitK8sApiVersion(obj.apiVersion)
|
|
741
|
+
let namespace = ''
|
|
742
|
+
if (!isClusterScopedKubernetesKind(kind)) {
|
|
743
|
+
const metaNs = metadataNamespaceTrimmed(meta)
|
|
744
|
+
const def =
|
|
745
|
+
typeof kustomizationDefaultNs === 'string' && kustomizationDefaultNs.trim() !== ''
|
|
746
|
+
? kustomizationDefaultNs.trim()
|
|
747
|
+
: ''
|
|
748
|
+
namespace = metaNs || def
|
|
749
|
+
}
|
|
750
|
+
return { group, version, kind, name, namespace }
|
|
660
751
|
}
|
|
661
752
|
|
|
662
753
|
/**
|
|
663
|
-
*
|
|
664
|
-
* @param {string}
|
|
665
|
-
* @returns {
|
|
754
|
+
* Читає k8s YAML і повертає корені документів-об’єктів (після modeline, якщо він є).
|
|
755
|
+
* @param {string} abs абсолютний шлях до файлу
|
|
756
|
+
* @returns {Promise<Record<string, unknown>[]>} масив коренів-об’єктів YAML-документів (без масивів на корені).
|
|
666
757
|
*/
|
|
667
|
-
function
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
758
|
+
async function readK8sYamlDocumentRootsForInventory(abs) {
|
|
759
|
+
let raw
|
|
760
|
+
try {
|
|
761
|
+
raw = await readFile(abs, 'utf8')
|
|
762
|
+
} catch {
|
|
763
|
+
return []
|
|
764
|
+
}
|
|
765
|
+
const lines = toLines(raw)
|
|
766
|
+
const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
|
|
767
|
+
/** @type {unknown[]} */
|
|
768
|
+
const roots = parseK8sYamlDocumentObjectRoots(body)
|
|
769
|
+
/** @type {Record<string, unknown>[]} */
|
|
770
|
+
const out = []
|
|
771
|
+
for (const r of roots) {
|
|
772
|
+
if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
|
|
773
|
+
out.push(/** @type {Record<string, unknown>} */ (r))
|
|
774
|
+
}
|
|
673
775
|
}
|
|
776
|
+
return out
|
|
674
777
|
}
|
|
675
778
|
|
|
676
779
|
/**
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
* @param {string}
|
|
680
|
-
* @
|
|
780
|
+
* Збирає дескриптори ресурсів з **`resources` / `bases` / `components` / `crds`** для одного дерева kustomization.
|
|
781
|
+
* Повторний вхід у той самий **`kustomization.yaml`** дає порожній внесок (як у **`collectKustomizeManagedRelPaths`**).
|
|
782
|
+
* @param {string} kustAbs абсолютний шлях до **kustomization.yaml**
|
|
783
|
+
* @param {string} rootNorm нормалізований абсолютний корінь репозиторію
|
|
784
|
+
* @param {Set<string>} visitedKustomization нормалізовані абсолютні шляхи відвіданих **kustomization.yaml**
|
|
785
|
+
* @returns {Promise<KustomizeResourceDescriptor[]>} плоский список дескрипторів із дерева **resources** / **bases** / **components** / **crds**.
|
|
681
786
|
*/
|
|
682
|
-
export function
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
}
|
|
787
|
+
export async function collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization) {
|
|
788
|
+
const normKust = resolve(kustAbs)
|
|
789
|
+
if (visitedKustomization.has(normKust)) {
|
|
790
|
+
return []
|
|
791
|
+
}
|
|
792
|
+
visitedKustomization.add(normKust)
|
|
687
793
|
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
if (!/metadata:/u.test(raw)) return false
|
|
697
|
-
if (!/name:\s*\S+/u.test(raw)) return false
|
|
698
|
-
return true
|
|
699
|
-
}
|
|
794
|
+
let raw
|
|
795
|
+
try {
|
|
796
|
+
raw = await readFile(normKust, 'utf8')
|
|
797
|
+
} catch {
|
|
798
|
+
return []
|
|
799
|
+
}
|
|
800
|
+
const lines = toLines(raw)
|
|
801
|
+
const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
|
|
700
802
|
|
|
701
|
-
/**
|
|
702
|
-
|
|
703
|
-
|
|
803
|
+
/** @type {import('yaml').Document[] | undefined} */
|
|
804
|
+
let docs
|
|
805
|
+
try {
|
|
806
|
+
docs = parseAllDocuments(body)
|
|
807
|
+
} catch {
|
|
808
|
+
return []
|
|
809
|
+
}
|
|
810
|
+
const first = docs[0]?.toJSON()
|
|
811
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
|
|
812
|
+
return []
|
|
813
|
+
}
|
|
814
|
+
const rec = /** @type {Record<string, unknown>} */ (first)
|
|
815
|
+
const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
|
|
816
|
+
const kustDir = dirname(normKust)
|
|
817
|
+
const pathRefs = resourcePathRefsFromKustomizationObject(first)
|
|
818
|
+
|
|
819
|
+
/** @type {KustomizeResourceDescriptor[]} */
|
|
820
|
+
const out = []
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* @param {string} ref шлях з resources/bases/…
|
|
824
|
+
* @returns {Promise<void>}
|
|
825
|
+
*/
|
|
826
|
+
async function handleResourceDescriptorPathRef(ref) {
|
|
827
|
+
if (typeof ref !== 'string' || ref.includes('://')) {
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
const resolved = resolve(kustDir, ref)
|
|
831
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
|
|
832
|
+
return
|
|
833
|
+
}
|
|
834
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
835
|
+
let st
|
|
836
|
+
try {
|
|
837
|
+
st = await stat(resolved)
|
|
838
|
+
} catch {
|
|
839
|
+
st = undefined
|
|
840
|
+
}
|
|
841
|
+
if (st === undefined) {
|
|
842
|
+
return
|
|
843
|
+
}
|
|
844
|
+
if (st.isFile() && YAML_EXTENSION_RE.test(resolved)) {
|
|
845
|
+
const roots = await readK8sYamlDocumentRootsForInventory(resolved)
|
|
846
|
+
for (const o of roots) {
|
|
847
|
+
const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
|
|
848
|
+
if (d !== null) {
|
|
849
|
+
out.push(d)
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return
|
|
853
|
+
}
|
|
854
|
+
if (!st.isDirectory()) {
|
|
855
|
+
return
|
|
856
|
+
}
|
|
857
|
+
const childK = existsSync(join(resolved, 'kustomization.yaml')) ? join(resolved, 'kustomization.yaml') : null
|
|
858
|
+
if (childK !== null) {
|
|
859
|
+
const sub = await collectResourceDescriptorsForKustomizationWalk(childK, rootNorm, visitedKustomization)
|
|
860
|
+
out.push(...sub)
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
for (const ref of pathRefs) {
|
|
865
|
+
await handleResourceDescriptorPathRef(ref)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return out
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Витягує записи з явним **target** з **patches** / **patchesJson6902**.
|
|
873
|
+
* @param {unknown} obj перший документ Kustomization
|
|
874
|
+
* @returns {Array<{ section: string, index: number, target: unknown }>} пари **section** + індекс (1-based) і **target** з YAML.
|
|
875
|
+
*/
|
|
876
|
+
function extractExplicitPatchTargetsFromKustomization(obj) {
|
|
877
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
878
|
+
return []
|
|
879
|
+
}
|
|
880
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
881
|
+
/** @type {Array<{ section: string, index: number, target: unknown }>} */
|
|
882
|
+
const out = []
|
|
883
|
+
/**
|
|
884
|
+
* @param {string} section ім’я поля
|
|
885
|
+
* @param {unknown} arr масив з YAML
|
|
886
|
+
* @returns {void}
|
|
887
|
+
*/
|
|
888
|
+
const push = (section, arr) => {
|
|
889
|
+
if (!Array.isArray(arr)) {
|
|
890
|
+
return
|
|
891
|
+
}
|
|
892
|
+
let i = 0
|
|
893
|
+
for (const item of arr) {
|
|
894
|
+
i++
|
|
895
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
896
|
+
const it = /** @type {Record<string, unknown>} */ (item)
|
|
897
|
+
if ('target' in it) {
|
|
898
|
+
out.push({ section, index: i, target: it.target })
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
push('patches', rec.patches)
|
|
904
|
+
push('patchesJson6902', rec.patchesJson6902)
|
|
905
|
+
return out
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Людинозчитуваний опис **target** для повідомлення про помилку.
|
|
910
|
+
* @param {unknown} target об’єкт **target**
|
|
911
|
+
* @returns {string} короткий рядок
|
|
912
|
+
*/
|
|
913
|
+
function formatKustomizePatchTargetForMessage(target) {
|
|
914
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
|
915
|
+
return String(target)
|
|
916
|
+
}
|
|
917
|
+
const t = /** @type {Record<string, unknown>} */ (target)
|
|
918
|
+
const parts = []
|
|
919
|
+
const g = t.group
|
|
920
|
+
const v = t.version
|
|
921
|
+
const k = t.kind
|
|
922
|
+
const n = t.name
|
|
923
|
+
const ns = t.namespace
|
|
924
|
+
if (typeof g === 'string' && g.trim() !== '') {
|
|
925
|
+
parts.push(`group=${g.trim()}`)
|
|
926
|
+
}
|
|
927
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
928
|
+
parts.push(`version=${v.trim()}`)
|
|
929
|
+
}
|
|
930
|
+
if (typeof k === 'string' && k.trim() !== '') {
|
|
931
|
+
parts.push(`kind=${k.trim()}`)
|
|
932
|
+
}
|
|
933
|
+
if (typeof n === 'string' && n.trim() !== '') {
|
|
934
|
+
parts.push(`name=${n.trim()}`)
|
|
935
|
+
}
|
|
936
|
+
if (typeof ns === 'string' && ns.trim() !== '') {
|
|
937
|
+
parts.push(`namespace=${ns.trim()}`)
|
|
938
|
+
}
|
|
939
|
+
return parts.length > 0 ? parts.join(', ') : JSON.stringify(t)
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Явні **patches[].target** / **patchesJson6902[].target** — ресурс має бути в інвентарі.
|
|
944
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
945
|
+
* @param {Record<string, unknown>} first корінь Kustomization
|
|
946
|
+
* @param {KustomizeResourceDescriptor[]} catalog інвентар resources/bases/…
|
|
947
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
948
|
+
* @returns {void}
|
|
949
|
+
*/
|
|
950
|
+
function failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail) {
|
|
951
|
+
for (const { section, index, target } of extractExplicitPatchTargetsFromKustomization(first)) {
|
|
952
|
+
if (
|
|
953
|
+
shouldValidateKustomizePatchTarget(target) &&
|
|
954
|
+
!kustomizeResourceCatalogMatchesPatchTarget(catalog, target)
|
|
955
|
+
) {
|
|
956
|
+
fail(
|
|
957
|
+
`${rel}: ${section}[${index}].target — немає відповідного ресурсу в resources/bases/components/crds (рекурсивно): ${formatKustomizePatchTargetForMessage(target)}`
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Документи з YAML-файлу мають мати дескриптор у **catalog** (інвентар resources).
|
|
965
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
966
|
+
* @param {string} resolvedAbs абсолютний шлях до patch-файлу
|
|
967
|
+
* @param {string} root корінь репо
|
|
968
|
+
* @param {string} relPatchFallback якщо **relative** дає порожньо
|
|
969
|
+
* @param {string} violationIntro префікс повідомлення (`patches[1] path` або `patchesStrategicMerge[2]`)
|
|
970
|
+
* @param {KustomizeResourceDescriptor[]} catalog інвентар
|
|
971
|
+
* @param {string} kustNs default namespace
|
|
972
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
973
|
+
* @returns {Promise<void>}
|
|
974
|
+
*/
|
|
975
|
+
async function failIfYamlFileRootsMissingFromCatalog(
|
|
976
|
+
rel,
|
|
977
|
+
resolvedAbs,
|
|
978
|
+
root,
|
|
979
|
+
relPatchFallback,
|
|
980
|
+
violationIntro,
|
|
981
|
+
catalog,
|
|
982
|
+
kustNs,
|
|
983
|
+
fail
|
|
984
|
+
) {
|
|
985
|
+
const roots = await readK8sYamlDocumentRootsForInventory(resolvedAbs)
|
|
986
|
+
let docIdx = 0
|
|
987
|
+
for (const o of roots) {
|
|
988
|
+
docIdx++
|
|
989
|
+
const d = kustomizeResourceDescriptorFromManifest(o, kustNs)
|
|
990
|
+
if (d !== null && !catalog.some(c => kustomizeResourceDescriptorsIdentityEqual(c, d))) {
|
|
991
|
+
const relPatch = (relative(root, resolvedAbs) || relPatchFallback).replaceAll('\\', '/')
|
|
992
|
+
fail(
|
|
993
|
+
`${rel}: ${violationIntro} «${relPatch}» документ ${docIdx} — у каталозі resources немає ресурсу ${d.kind}/${d.name} (namespace=${d.namespace || '(порожньо)'}, apiVersion group/version=${d.group || 'core'}/${d.version})`
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Вирішує відносний шлях до існуючого **.yaml** під root і перевіряє, що це файл.
|
|
1001
|
+
* @param {string} kustDir каталог kustomization
|
|
1002
|
+
* @param {string} pathStr відносний шлях
|
|
1003
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1004
|
+
* @returns {Promise<string | null>} абсолютний шлях або null
|
|
1005
|
+
*/
|
|
1006
|
+
async function resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm) {
|
|
1007
|
+
const resolved = resolve(kustDir, pathStr)
|
|
1008
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, resolved) || !existsSync(resolved)) {
|
|
1009
|
+
return null
|
|
1010
|
+
}
|
|
1011
|
+
/** @type {import('node:fs').Stats | null} */
|
|
1012
|
+
let st = null
|
|
1013
|
+
try {
|
|
1014
|
+
st = await stat(resolved)
|
|
1015
|
+
} catch {
|
|
1016
|
+
st = null
|
|
1017
|
+
}
|
|
1018
|
+
if (st === null || !st.isFile() || !YAML_EXTENSION_RE.test(resolved)) {
|
|
1019
|
+
return null
|
|
1020
|
+
}
|
|
1021
|
+
return resolved
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Один елемент **patches[]** лише з **path** (без **target**, без inline patch): корені файлу проти інвентарю.
|
|
1026
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1027
|
+
* @param {unknown} p елемент **patches**
|
|
1028
|
+
* @param {number} pIdx 1-based індекс у масиві
|
|
1029
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
1030
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1031
|
+
* @param {string} root корінь репо
|
|
1032
|
+
* @param {KustomizeResourceDescriptor[]} catalog інвентар
|
|
1033
|
+
* @param {string} kustNs default namespace з kustomization
|
|
1034
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1035
|
+
* @returns {Promise<void>}
|
|
1036
|
+
*/
|
|
1037
|
+
async function failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail) {
|
|
1038
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) {
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
1042
|
+
const hasTargetKey = 'target' in pr && pr.target !== undefined && pr.target !== null
|
|
1043
|
+
const pathStr = typeof pr.path === 'string' ? pr.path.trim() : ''
|
|
1044
|
+
const inlinePatch = typeof pr.patch === 'string' && pr.patch.trim() !== ''
|
|
1045
|
+
if (hasTargetKey || pathStr === '' || inlinePatch || pathStr.includes('://')) {
|
|
1046
|
+
return
|
|
1047
|
+
}
|
|
1048
|
+
const resolved = await resolveExistingYamlFileUnderRoot(kustDir, pathStr, rootNorm)
|
|
1049
|
+
if (resolved === null) {
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
await failIfYamlFileRootsMissingFromCatalog(
|
|
1053
|
+
rel,
|
|
1054
|
+
resolved,
|
|
1055
|
+
root,
|
|
1056
|
+
pathStr,
|
|
1057
|
+
`patches[${pIdx}] path`,
|
|
1058
|
+
catalog,
|
|
1059
|
+
kustNs,
|
|
1060
|
+
fail
|
|
1061
|
+
)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* **patches[]** лише з **path** (без **target**, без inline patch) — документи у файлі мають збігатися з інвентарем.
|
|
1066
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1067
|
+
* @param {unknown} patches поле **patches**
|
|
1068
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
1069
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1070
|
+
* @param {string} root корінь репо
|
|
1071
|
+
* @param {KustomizeResourceDescriptor[]} catalog інвентар
|
|
1072
|
+
* @param {string} kustNs default namespace з kustomization
|
|
1073
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1074
|
+
* @returns {Promise<void>}
|
|
1075
|
+
*/
|
|
1076
|
+
async function failIfPathOnlyPatchesNotInCatalog(rel, patches, kustDir, rootNorm, root, catalog, kustNs, fail) {
|
|
1077
|
+
if (!Array.isArray(patches)) {
|
|
1078
|
+
return
|
|
1079
|
+
}
|
|
1080
|
+
let pIdx = 0
|
|
1081
|
+
for (const p of patches) {
|
|
1082
|
+
pIdx++
|
|
1083
|
+
await failIfOnePathOnlyPatchNotInCatalog(rel, p, pIdx, kustDir, rootNorm, root, catalog, kustNs, fail)
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* **patchesStrategicMerge** — кожен документ у файлі має збігатися з інвентарем.
|
|
1089
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1090
|
+
* @param {unknown} sm поле **patchesStrategicMerge**
|
|
1091
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
1092
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1093
|
+
* @param {string} root корінь репо
|
|
1094
|
+
* @param {KustomizeResourceDescriptor[]} catalog інвентар
|
|
1095
|
+
* @param {string} kustNs default namespace з kustomization
|
|
1096
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1097
|
+
* @returns {Promise<void>}
|
|
1098
|
+
*/
|
|
1099
|
+
async function failIfStrategicMergePatchesNotInCatalog(rel, sm, kustDir, rootNorm, root, catalog, kustNs, fail) {
|
|
1100
|
+
if (!Array.isArray(sm)) {
|
|
1101
|
+
return
|
|
1102
|
+
}
|
|
1103
|
+
let smIdx = 0
|
|
1104
|
+
for (const ref of sm) {
|
|
1105
|
+
smIdx++
|
|
1106
|
+
if (typeof ref === 'string' && ref.trim() !== '' && !ref.includes('://')) {
|
|
1107
|
+
const resolved = await resolveExistingYamlFileUnderRoot(kustDir, ref.trim(), rootNorm)
|
|
1108
|
+
if (resolved !== null) {
|
|
1109
|
+
await failIfYamlFileRootsMissingFromCatalog(
|
|
1110
|
+
rel,
|
|
1111
|
+
resolved,
|
|
1112
|
+
root,
|
|
1113
|
+
ref,
|
|
1114
|
+
`patchesStrategicMerge[${smIdx}]`,
|
|
1115
|
+
catalog,
|
|
1116
|
+
kustNs,
|
|
1117
|
+
fail
|
|
1118
|
+
)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Один **`kustomization.yaml`**: patch **target**, **path** без target, **patchesStrategicMerge**.
|
|
1126
|
+
* @param {string} root корінь репозиторію
|
|
1127
|
+
* @param {string} kustAbs абсолютний шлях до файлу
|
|
1128
|
+
* @param {string} rootNorm нормалізований корінь
|
|
1129
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1130
|
+
* @returns {Promise<void>}
|
|
1131
|
+
*/
|
|
1132
|
+
async function validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail) {
|
|
1133
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
1134
|
+
let raw
|
|
1135
|
+
try {
|
|
1136
|
+
raw = await readFile(kustAbs, 'utf8')
|
|
1137
|
+
} catch (error) {
|
|
1138
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1139
|
+
fail(`${rel}: не вдалося прочитати для перевірки patch target (${msg})`)
|
|
1140
|
+
return
|
|
1141
|
+
}
|
|
1142
|
+
const lines = toLines(raw)
|
|
1143
|
+
const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
|
|
1144
|
+
/** @type {import('yaml').Document[] | null} */
|
|
1145
|
+
let docs = null
|
|
1146
|
+
try {
|
|
1147
|
+
docs = parseAllDocuments(body)
|
|
1148
|
+
} catch {
|
|
1149
|
+
fail(`${rel}: не вдалося розпарсити YAML для перевірки patch target`)
|
|
1150
|
+
return
|
|
1151
|
+
}
|
|
1152
|
+
const first = docs[0]?.toJSON()
|
|
1153
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) {
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
const rec = /** @type {Record<string, unknown>} */ (first)
|
|
1157
|
+
if (rec.kind !== 'Kustomization') {
|
|
1158
|
+
return
|
|
1159
|
+
}
|
|
1160
|
+
const visited = new Set()
|
|
1161
|
+
const catalog = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visited)
|
|
1162
|
+
const kustDir = dirname(resolve(kustAbs))
|
|
1163
|
+
const kustNs = typeof rec.namespace === 'string' && rec.namespace.trim() !== '' ? rec.namespace.trim() : ''
|
|
1164
|
+
failIfExplicitPatchTargetsNotInCatalog(rel, first, catalog, fail)
|
|
1165
|
+
await failIfPathOnlyPatchesNotInCatalog(rel, rec.patches, kustDir, rootNorm, root, catalog, kustNs, fail)
|
|
1166
|
+
await failIfStrategicMergePatchesNotInCatalog(rel, rec.patchesStrategicMerge, kustDir, rootNorm, root, catalog, kustNs, fail)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Перевіряє всі **`kustomization.yaml`** під **`k8s`**: **target** patch і strategic-merge посилання не вказують на ресурс поза інвентарем **resources** / **bases** / **components** / **crds**.
|
|
1171
|
+
* @param {string} root корінь репозиторію
|
|
1172
|
+
* @param {string[]} yamlFilesAbs абсолютні шляхи до yaml під k8s
|
|
1173
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1174
|
+
* @returns {Promise<void>}
|
|
1175
|
+
*/
|
|
1176
|
+
async function validateKustomizationPatchTargetsResolved(root, yamlFilesAbs, fail) {
|
|
1177
|
+
const rootNorm = resolve(root)
|
|
1178
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
1179
|
+
await validatePatchTargetsOneKustomizationFile(root, kustAbs, rootNorm, fail)
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Чи це **`k8s/base/kustomization.yaml`** (перевірка обов’язкового непорожнього **`namespace:`**).
|
|
1185
|
+
* @param {string} rel шлях від кореня репозиторію
|
|
1186
|
+
* @returns {boolean} true для шляху виду `…/k8s/base/kustomization.yaml`
|
|
1187
|
+
*/
|
|
1188
|
+
export function isBaseKustomizationPath(rel) {
|
|
1189
|
+
const n = rel.replaceAll('\\', '/')
|
|
1190
|
+
return K8S_BASE_KUSTOMIZATION_PATH_RE.test(n)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Чи є в Kustomization для **`base`** завжди обов’язкове непорожнє поле **`namespace:`** (k8s.mdc).
|
|
1195
|
+
* @param {unknown} obj перший документ YAML
|
|
1196
|
+
* @returns {string | null} текст порушення або null, якщо ок
|
|
1197
|
+
*/
|
|
1198
|
+
export function baseKustomizationNamespaceViolation(obj) {
|
|
1199
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
1200
|
+
return 'у base/kustomization.yaml завжди має бути непорожній namespace: (див. k8s.mdc)'
|
|
1201
|
+
}
|
|
1202
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
1203
|
+
const ns = rec.namespace
|
|
1204
|
+
if (typeof ns === 'string' && ns.trim() !== '') {
|
|
1205
|
+
return null
|
|
1206
|
+
}
|
|
1207
|
+
return 'у base/kustomization.yaml завжди додай непорожній namespace: (наприклад namespace: dev; див. k8s.mdc)'
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Збирає всі `*.yaml` та `*.yml` під деревом від кореня cwd, якщо шлях містить сегмент `k8s` (для `.yml` далі — помилка перейменування).
|
|
1212
|
+
* @param {string} root корінь репозиторію (cwd)
|
|
1213
|
+
* @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
|
|
1214
|
+
*/
|
|
1215
|
+
async function findK8sYamlFiles(root) {
|
|
1216
|
+
/** @type {string[]} */
|
|
1217
|
+
const out = []
|
|
1218
|
+
await walkDir(root, p => {
|
|
1219
|
+
if (!pathHasK8sSegment(p)) return
|
|
1220
|
+
if (!YAML_EXTENSION_RE.test(p)) return
|
|
1221
|
+
out.push(p)
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
return out.toSorted((a, b) => a.localeCompare(b))
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Тіло YAML для політик (Ingress, BackendConfig тощо): якщо перший рядок — modeline `$schema`, береться вміст після нього.
|
|
1229
|
+
* @param {string[]} lines рядки файлу
|
|
1230
|
+
* @returns {string} фрагмент для `parseAllDocuments`
|
|
1231
|
+
*/
|
|
1232
|
+
function k8sYamlBodyForDocumentParse(lines) {
|
|
1233
|
+
if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
|
|
1234
|
+
return yamlBodyAfterModeline(lines)
|
|
1235
|
+
}
|
|
1236
|
+
return lines.join('\n')
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* Оновлює прапорці наявності **BackendConfig** / інших **kind** у документі.
|
|
1241
|
+
* @param {unknown} kind значення **kind**
|
|
1242
|
+
* @param {{ hasBc: boolean, hasOther: boolean }} acc накопичувач
|
|
1243
|
+
* @returns {void}
|
|
1244
|
+
*/
|
|
1245
|
+
function updateBackendConfigKindFlags(kind, acc) {
|
|
1246
|
+
if (kind === 'BackendConfig') {
|
|
1247
|
+
acc.hasBc = true
|
|
1248
|
+
return
|
|
1249
|
+
}
|
|
1250
|
+
if (kind !== undefined && kind !== null && String(kind).trim() !== '') {
|
|
1251
|
+
acc.hasOther = true
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Чи всі нетривіальні документи у тілі — **`kind: BackendConfig`**, чи є змішування з іншими kind.
|
|
1257
|
+
* @param {string} body YAML без обов’язкового modeline (див. `k8sYamlBodyForDocumentParse`)
|
|
1258
|
+
* @returns {'none' | 'only' | 'mixed' | 'unparsed'} unparsed — не вдалося розпарсити YAML
|
|
1259
|
+
*/
|
|
1260
|
+
export function classifyBackendConfigManifestPresence(body) {
|
|
1261
|
+
/** @type {import('yaml').Document[]} */
|
|
1262
|
+
let docs
|
|
1263
|
+
try {
|
|
1264
|
+
docs = parseAllDocuments(body)
|
|
1265
|
+
} catch {
|
|
1266
|
+
return 'unparsed'
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
const acc = { hasBc: false, hasOther: false }
|
|
1270
|
+
for (const doc of docs) {
|
|
1271
|
+
if (doc.errors.length === 0) {
|
|
1272
|
+
const obj = doc.toJSON()
|
|
1273
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
1274
|
+
updateBackendConfigKindFlags(obj.kind, acc)
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (!acc.hasBc) {
|
|
1280
|
+
return 'none'
|
|
1281
|
+
}
|
|
1282
|
+
if (acc.hasOther) {
|
|
1283
|
+
return 'mixed'
|
|
1284
|
+
}
|
|
1285
|
+
return 'only'
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/**
|
|
1289
|
+
* Видаляє під **`k8s`** YAML-файли, що містять **лише** ресурси **BackendConfig**; змішані файли — `fail`.
|
|
1290
|
+
* @param {string} root корінь репозиторію
|
|
1291
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1292
|
+
* @param {(msg: string) => void} pass реєстрація успіху
|
|
1293
|
+
* @returns {Promise<void>}
|
|
1294
|
+
*/
|
|
1295
|
+
async function removeBackendConfigOnlyK8sYamlFiles(root, fail, pass) {
|
|
1296
|
+
const yamlFiles = await findK8sYamlFiles(root)
|
|
1297
|
+
for (const abs of yamlFiles) {
|
|
1298
|
+
const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
|
|
1299
|
+
try {
|
|
1300
|
+
const raw = await readFile(abs, 'utf8')
|
|
1301
|
+
const lines = toLines(raw)
|
|
1302
|
+
const body = k8sYamlBodyForDocumentParse(lines)
|
|
1303
|
+
const bcPresence = classifyBackendConfigManifestPresence(body)
|
|
1304
|
+
|
|
1305
|
+
if (bcPresence === 'mixed') {
|
|
1306
|
+
fail(
|
|
1307
|
+
`${rel}: у файлі разом BackendConfig та інші kind — винеси BackendConfig окремо або прибери вручну; автоматичне видалення не застосовується (див. k8s.mdc)`
|
|
1308
|
+
)
|
|
1309
|
+
} else if (bcPresence === 'only') {
|
|
1310
|
+
try {
|
|
1311
|
+
await unlink(abs)
|
|
1312
|
+
pass(`${rel}: видалено (лише kind: BackendConfig; див. k8s.mdc)`)
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
fail(`${rel}: не вдалося видалити BackendConfig-файл (${error.message})`)
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
fail(`${rel}: не вдалося прочитати для перевірки BackendConfig (${error.message})`)
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Прибирає BOM і ділить на рядки.
|
|
1325
|
+
* @param {string} content вміст файлу
|
|
1326
|
+
* @returns {string[]} рядки без BOM на початку
|
|
1327
|
+
*/
|
|
1328
|
+
function toLines(content) {
|
|
1329
|
+
const body = content.startsWith('\uFEFF') ? content.slice(1) : content
|
|
1330
|
+
return body.split(YAML_LINE_SPLIT_RE)
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Вміст після першого рядка (modeline), без провідних порожніх рядків.
|
|
1335
|
+
* @param {string[]} lines рядки файлу
|
|
1336
|
+
* @returns {string} тіло для парсингу першого YAML-документа
|
|
1337
|
+
*/
|
|
1338
|
+
function yamlBodyAfterModeline(lines) {
|
|
1339
|
+
let i = 1
|
|
1340
|
+
while (i < lines.length && lines[i].trim() === '') i++
|
|
1341
|
+
return lines.slice(i).join('\n')
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Читає k8s YAML і повертає фрагмент після modeline `$schema`, якщо перший рядок — modeline.
|
|
1346
|
+
* Потрібно для парної перевірки **`svc.yaml`** / **`svc-hl.yaml`**.
|
|
1347
|
+
* @param {string} abs абсолютний шлях до файлу
|
|
1348
|
+
* @returns {Promise<string>} тіло для `parseAllDocuments`
|
|
1349
|
+
*/
|
|
1350
|
+
async function readK8sYamlBodyAfterModelineForSvcPair(abs) {
|
|
1351
|
+
const raw = await readFile(abs, 'utf8')
|
|
1352
|
+
const lines = toLines(raw)
|
|
1353
|
+
if (lines.length > 0 && MODELINE_RE.test(lines[0])) {
|
|
1354
|
+
return yamlBodyAfterModeline(lines)
|
|
1355
|
+
}
|
|
1356
|
+
return lines.join('\n')
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/**
|
|
1360
|
+
* Розбирає YAML на корені документів (ігнорує зламані документи).
|
|
1361
|
+
* @param {string} body фрагмент YAML
|
|
1362
|
+
* @returns {unknown[]} масив успішно розібраних коренів YAML-документів
|
|
1363
|
+
*/
|
|
1364
|
+
function parseK8sYamlDocumentObjectRoots(body) {
|
|
1365
|
+
try {
|
|
1366
|
+
return parseAllDocuments(body)
|
|
1367
|
+
.filter(d => d.errors.length === 0)
|
|
1368
|
+
.map(d => d.toJSON())
|
|
1369
|
+
.filter(x => x !== null && x !== undefined && typeof x === 'object' && !Array.isArray(x))
|
|
1370
|
+
} catch {
|
|
1371
|
+
return []
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Перший YAML-документ (до наступного `---` на окремому рядку).
|
|
1377
|
+
* @param {string} body фрагмент YAML
|
|
1378
|
+
* @returns {string} перший документ без зайвих пробілів по краях
|
|
1379
|
+
*/
|
|
1380
|
+
function firstYamlDocument(body) {
|
|
1381
|
+
const lines = body.split(YAML_LINE_SPLIT_RE)
|
|
1382
|
+
const out = []
|
|
1383
|
+
for (const line of lines) {
|
|
1384
|
+
if (YAML_DOC_SEPARATOR_LINE_RE.test(line)) {
|
|
1385
|
+
break
|
|
1386
|
+
}
|
|
1387
|
+
out.push(line)
|
|
1388
|
+
}
|
|
1389
|
+
return out.join('\n').trim()
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Витягує `apiVersion` та `kind` з тексту документа (без повного YAML-парсера).
|
|
1394
|
+
* @param {string} doc фрагмент YAML одного документа
|
|
1395
|
+
* @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
|
|
1396
|
+
*/
|
|
1397
|
+
function extractApiVersionAndKind(doc) {
|
|
1398
|
+
/** @type {string | undefined} */
|
|
1399
|
+
let apiVersion
|
|
1400
|
+
/** @type {string | undefined} */
|
|
1401
|
+
let kind
|
|
1402
|
+
for (const line of doc.split(YAML_LINE_SPLIT_RE)) {
|
|
1403
|
+
if (apiVersion === undefined) {
|
|
1404
|
+
const av = line.match(API_VERSION_FIELD_RE)
|
|
1405
|
+
if (av) {
|
|
1406
|
+
apiVersion = trimYamlScalarQuotes(av[1])
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
if (kind === undefined) {
|
|
1410
|
+
const k = line.match(KIND_FIELD_RE)
|
|
1411
|
+
if (k) {
|
|
1412
|
+
kind = trimYamlScalarQuotes(k[1])
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if (apiVersion !== undefined && kind !== undefined) {
|
|
1416
|
+
break
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return { apiVersion, kind }
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Чи перший YAML-документ (до `---`) — **HttpBackendGroup** з API **alb.yc.io/v1alpha1** (Yandex ALB).
|
|
1424
|
+
* Для таких файлів **check-k8s** не вимагає modeline `# yaml-language-server: $schema=…` і забороняє його.
|
|
1425
|
+
* @param {string} yamlBody вміст файлу або фрагмент після modeline
|
|
1426
|
+
* @returns {boolean} true, якщо `apiVersion`/`kind` першого документа збігаються з винятком
|
|
1427
|
+
*/
|
|
1428
|
+
export function k8sYamlFirstDocIsAlbYcHttpBackendGroup(yamlBody) {
|
|
1429
|
+
const first = firstYamlDocument(yamlBody)
|
|
1430
|
+
const { apiVersion, kind } = extractApiVersionAndKind(first)
|
|
1431
|
+
return apiVersion === 'alb.yc.io/v1alpha1' && kind === 'HttpBackendGroup'
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Чи вміст overlay **`ru/kustomization.yaml`** містить Kustomize patch видалення **HealthCheckPolicy**.
|
|
1436
|
+
* @param {string} raw повний текст файлу
|
|
1437
|
+
* @returns {boolean} true, якщо є `$patch: delete` і блоки kind/metadata для HealthCheckPolicy
|
|
1438
|
+
*/
|
|
1439
|
+
export function ruKustomizationHasHealthCheckDeletePatch(raw) {
|
|
1440
|
+
if (!HEALTHCHECK_DELETE_RE.test(raw)) return false
|
|
1441
|
+
if (!HEALTHCHECK_KIND_RE.test(raw)) return false
|
|
1442
|
+
if (!METADATA_LINE_RE.test(raw)) return false
|
|
1443
|
+
if (!NAME_NON_EMPTY_RE.test(raw)) return false
|
|
1444
|
+
return true
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
/**
|
|
1448
|
+
* Чи абсолютний шлях лежить усередині кореня репозиторію (без виходу через `..`).
|
|
1449
|
+
* @param {string} rootAbs абсолютний корінь
|
|
704
1450
|
* @param {string} fileAbs абсолютний шлях до файлу
|
|
705
1451
|
* @returns {boolean} true, якщо `fileAbs` усередині `rootAbs`
|
|
706
1452
|
*/
|
|
@@ -810,6 +1556,173 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
|
|
|
810
1556
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
811
1557
|
}
|
|
812
1558
|
|
|
1559
|
+
/**
|
|
1560
|
+
* Реєструє порушення, якщо в JSON6902-операціях є **remove** і **add** на один **path**.
|
|
1561
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1562
|
+
* @param {string} label фрагмент повідомлення (наприклад `patches[1] inline JSON6902`)
|
|
1563
|
+
* @param {string} patchText текст patch
|
|
1564
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1565
|
+
* @returns {void}
|
|
1566
|
+
*/
|
|
1567
|
+
function failIfJson6902RemoveAddConflictOnSamePath(rel, label, patchText, fail) {
|
|
1568
|
+
const ops = collectJson6902OperationsFromPatchText(patchText)
|
|
1569
|
+
const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
|
|
1570
|
+
if (bad.length > 0) {
|
|
1571
|
+
fail(`${rel}: ${label}: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Зовнішній patch-файл (масив JSON6902): remove+add на один path.
|
|
1577
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1578
|
+
* @param {string} resolved абсолютний шлях до файлу patch
|
|
1579
|
+
* @param {string} root корінь репо
|
|
1580
|
+
* @param {string} patchRef відносне посилання з kustomization
|
|
1581
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1582
|
+
* @returns {Promise<void>}
|
|
1583
|
+
*/
|
|
1584
|
+
async function auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail) {
|
|
1585
|
+
/** @type {import('node:fs').Stats | null} */
|
|
1586
|
+
let st = null
|
|
1587
|
+
try {
|
|
1588
|
+
st = await stat(resolved)
|
|
1589
|
+
} catch {
|
|
1590
|
+
st = null
|
|
1591
|
+
}
|
|
1592
|
+
if (st === null || !st.isFile()) {
|
|
1593
|
+
return
|
|
1594
|
+
}
|
|
1595
|
+
let pRaw
|
|
1596
|
+
try {
|
|
1597
|
+
pRaw = await readFile(resolved, 'utf8')
|
|
1598
|
+
} catch {
|
|
1599
|
+
return
|
|
1600
|
+
}
|
|
1601
|
+
const ops = collectJson6902OperationsFromPatchText(pRaw)
|
|
1602
|
+
if (ops.length === 0) {
|
|
1603
|
+
return
|
|
1604
|
+
}
|
|
1605
|
+
const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
|
|
1606
|
+
if (bad.length === 0) {
|
|
1607
|
+
return
|
|
1608
|
+
}
|
|
1609
|
+
const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
|
|
1610
|
+
fail(`${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`)
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Один елемент **`patches[]`**: inline JSON6902 або зовнішній patch-файл.
|
|
1615
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1616
|
+
* @param {Record<string, unknown>} pr об’єкт patch
|
|
1617
|
+
* @param {number} patchIdx 1-based індекс у масиві
|
|
1618
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
1619
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1620
|
+
* @param {string} root корінь репо
|
|
1621
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1622
|
+
* @returns {Promise<void>}
|
|
1623
|
+
*/
|
|
1624
|
+
async function auditOneKustomizationJson6902Patch(
|
|
1625
|
+
rel,
|
|
1626
|
+
pr,
|
|
1627
|
+
patchIdx,
|
|
1628
|
+
kustAbs,
|
|
1629
|
+
rootNorm,
|
|
1630
|
+
root,
|
|
1631
|
+
fail
|
|
1632
|
+
) {
|
|
1633
|
+
if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
|
|
1634
|
+
failIfJson6902RemoveAddConflictOnSamePath(rel, `patches[${patchIdx}] inline JSON6902`, pr.patch, fail)
|
|
1635
|
+
}
|
|
1636
|
+
if (typeof pr.path !== 'string' || pr.path.trim() === '') {
|
|
1637
|
+
return
|
|
1638
|
+
}
|
|
1639
|
+
const patchRef = pr.path.trim()
|
|
1640
|
+
const resolved = resolve(dirname(kustAbs), patchRef)
|
|
1641
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, resolved) || !existsSync(resolved)) {
|
|
1642
|
+
return
|
|
1643
|
+
}
|
|
1644
|
+
await auditJson6902PatchExternalFile(rel, resolved, root, patchRef, fail)
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
/**
|
|
1648
|
+
* Усі **`patches[]`** у Kustomization: inline та зовнішні файли.
|
|
1649
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1650
|
+
* @param {unknown} patches поле **patches**
|
|
1651
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
1652
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1653
|
+
* @param {string} root корінь репо
|
|
1654
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1655
|
+
* @returns {Promise<void>}
|
|
1656
|
+
*/
|
|
1657
|
+
async function auditKustomizationPatchesJson6902(rel, patches, kustAbs, rootNorm, root, fail) {
|
|
1658
|
+
if (!Array.isArray(patches)) {
|
|
1659
|
+
return
|
|
1660
|
+
}
|
|
1661
|
+
let patchIdx = 0
|
|
1662
|
+
for (const p of patches) {
|
|
1663
|
+
patchIdx++
|
|
1664
|
+
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
1665
|
+
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
1666
|
+
await auditOneKustomizationJson6902Patch(rel, pr, patchIdx, kustAbs, rootNorm, root, fail)
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* Один YAML-документ: якщо це Kustomization — перевірка **patches** на JSON6902 remove+add.
|
|
1673
|
+
* @param {string} rel відносний шлях до kustomization.yaml
|
|
1674
|
+
* @param {unknown} rootObj корінь документа
|
|
1675
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
1676
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
1677
|
+
* @param {string} root корінь репо
|
|
1678
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1679
|
+
* @returns {Promise<void>}
|
|
1680
|
+
*/
|
|
1681
|
+
async function auditJson6902ForKustomizationYamlDoc(rel, rootObj, kustAbs, rootNorm, root, fail) {
|
|
1682
|
+
const rec = /** @type {Record<string, unknown>} */ (rootObj)
|
|
1683
|
+
if (rec.kind !== 'Kustomization') {
|
|
1684
|
+
return
|
|
1685
|
+
}
|
|
1686
|
+
await auditKustomizationPatchesJson6902(rel, rec.patches, kustAbs, rootNorm, root, fail)
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Один **`kustomization.yaml`**: JSON6902 remove+add на одному path.
|
|
1691
|
+
* @param {string} root корінь репозиторію
|
|
1692
|
+
* @param {string} rootNorm нормалізований корінь
|
|
1693
|
+
* @param {string} kustAbs абсолютний шлях до файлу
|
|
1694
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
1695
|
+
* @returns {Promise<void>}
|
|
1696
|
+
*/
|
|
1697
|
+
async function auditJson6902OneKustomizationYamlFile(root, rootNorm, kustAbs, fail) {
|
|
1698
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
1699
|
+
let raw
|
|
1700
|
+
try {
|
|
1701
|
+
raw = await readFile(kustAbs, 'utf8')
|
|
1702
|
+
} catch (error) {
|
|
1703
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
1704
|
+
fail(`${rel}: не вдалося прочитати для перевірки JSON6902 (${msg})`)
|
|
1705
|
+
return
|
|
1706
|
+
}
|
|
1707
|
+
const lines = toLines(raw)
|
|
1708
|
+
const body = lines.length > 0 && MODELINE_RE.test(lines[0]) ? yamlBodyAfterModeline(lines) : lines.join('\n')
|
|
1709
|
+
/** @type {import('yaml').Document[] | null} */
|
|
1710
|
+
let docs = null
|
|
1711
|
+
try {
|
|
1712
|
+
docs = parseAllDocuments(body)
|
|
1713
|
+
} catch {
|
|
1714
|
+
return
|
|
1715
|
+
}
|
|
1716
|
+
for (const doc of docs) {
|
|
1717
|
+
if (doc.errors.length === 0) {
|
|
1718
|
+
const rootObj = doc.toJSON()
|
|
1719
|
+
if (rootObj !== null && typeof rootObj === 'object' && !Array.isArray(rootObj)) {
|
|
1720
|
+
await auditJson6902ForKustomizationYamlDoc(rel, rootObj, kustAbs, rootNorm, root, fail)
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
813
1726
|
/**
|
|
814
1727
|
* Перевіряє всі **`kustomization.yaml`** під **`k8s`**: у inline **`patch`** і у зовнішніх patch-файлах не має бути **remove** і **add** на той самий **path**.
|
|
815
1728
|
* @param {string} root корінь репозиторію
|
|
@@ -819,97 +1732,26 @@ export function json6902PathsWithRemoveAndAddOnSamePath(ops) {
|
|
|
819
1732
|
*/
|
|
820
1733
|
async function validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFilesAbs, fail) {
|
|
821
1734
|
const rootNorm = resolve(root)
|
|
822
|
-
for (const kustAbs of yamlFilesAbs) {
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
/** @type {import('yaml').Document[] | null} */
|
|
839
|
-
let docs = null
|
|
840
|
-
try {
|
|
841
|
-
docs = parseAllDocuments(body)
|
|
842
|
-
} catch {
|
|
843
|
-
docs = null
|
|
844
|
-
}
|
|
845
|
-
if (docs !== null) {
|
|
846
|
-
for (const doc of docs) {
|
|
847
|
-
if (doc.errors.length === 0) {
|
|
848
|
-
const rootObj = doc.toJSON()
|
|
849
|
-
if (rootObj !== null && typeof rootObj === 'object' && !Array.isArray(rootObj)) {
|
|
850
|
-
const rec = /** @type {Record<string, unknown>} */ (rootObj)
|
|
851
|
-
if (rec.kind === 'Kustomization') {
|
|
852
|
-
const patches = rec.patches
|
|
853
|
-
if (Array.isArray(patches)) {
|
|
854
|
-
let patchIdx = 0
|
|
855
|
-
for (const p of patches) {
|
|
856
|
-
patchIdx++
|
|
857
|
-
if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
|
|
858
|
-
const pr = /** @type {Record<string, unknown>} */ (p)
|
|
859
|
-
if (typeof pr.patch === 'string' && pr.patch.trim() !== '') {
|
|
860
|
-
const ops = collectJson6902OperationsFromPatchText(pr.patch)
|
|
861
|
-
const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
|
|
862
|
-
if (bad.length > 0) {
|
|
863
|
-
fail(
|
|
864
|
-
`${rel}: patches[${patchIdx}] inline JSON6902: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
|
|
865
|
-
)
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
if (typeof pr.path === 'string' && pr.path.trim() !== '') {
|
|
869
|
-
const patchRef = pr.path.trim()
|
|
870
|
-
const resolved = resolve(dirname(kustAbs), patchRef)
|
|
871
|
-
if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && existsSync(resolved)) {
|
|
872
|
-
/** @type {import('node:fs').Stats | null} */
|
|
873
|
-
let st = null
|
|
874
|
-
try {
|
|
875
|
-
st = await stat(resolved)
|
|
876
|
-
} catch {
|
|
877
|
-
st = null
|
|
878
|
-
}
|
|
879
|
-
if (st !== null && st.isFile()) {
|
|
880
|
-
/** @type {string | undefined} */
|
|
881
|
-
let pRaw
|
|
882
|
-
try {
|
|
883
|
-
pRaw = await readFile(resolved, 'utf8')
|
|
884
|
-
} catch {
|
|
885
|
-
pRaw = undefined
|
|
886
|
-
}
|
|
887
|
-
if (pRaw !== undefined) {
|
|
888
|
-
const ops = collectJson6902OperationsFromPatchText(pRaw)
|
|
889
|
-
if (ops.length > 0) {
|
|
890
|
-
const bad = json6902PathsWithRemoveAndAddOnSamePath(ops)
|
|
891
|
-
if (bad.length > 0) {
|
|
892
|
-
const relPatch = (relative(root, resolved) || patchRef).replaceAll('\\', '/')
|
|
893
|
-
fail(
|
|
894
|
-
`${rel}: patch-файл «${relPatch}»: один path має і remove, і add — оформи як op: replace (k8s.mdc): ${bad.join(', ')}`
|
|
895
|
-
)
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
}
|
|
1735
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
1736
|
+
await auditJson6902OneKustomizationYamlFile(root, rootNorm, kustAbs, fail)
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
/**
|
|
1741
|
+
* Заборонений **kind: Ingress** у документі.
|
|
1742
|
+
* @param {string} rel відносний шлях до файлу
|
|
1743
|
+
* @param {number} docIndex 1-based індекс документа
|
|
1744
|
+
* @param {Record<string, unknown>} rec корінь маніфесту
|
|
1745
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
1746
|
+
* @returns {void}
|
|
1747
|
+
*/
|
|
1748
|
+
function failIfIngressInDocument(rel, docIndex, rec, fail) {
|
|
1749
|
+
if (rec.kind !== 'Ingress') {
|
|
1750
|
+
return
|
|
912
1751
|
}
|
|
1752
|
+
fail(
|
|
1753
|
+
`${rel}: знайдено kind: Ingress (документ ${docIndex}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
|
|
1754
|
+
)
|
|
913
1755
|
}
|
|
914
1756
|
|
|
915
1757
|
/**
|
|
@@ -932,17 +1774,33 @@ function scanIngressInYamlDocuments(rel, body, fail) {
|
|
|
932
1774
|
if (doc.errors.length === 0) {
|
|
933
1775
|
const obj = doc.toJSON()
|
|
934
1776
|
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
935
|
-
|
|
936
|
-
if (rec.kind === 'Ingress') {
|
|
937
|
-
fail(
|
|
938
|
-
`${rel}: знайдено kind: Ingress (документ ${di + 1}) — заміни на Gateway API: HTTPRoute (hr.yaml), HealthCheckPolicy (hc.yaml) (див. k8s.mdc)`
|
|
939
|
-
)
|
|
940
|
-
}
|
|
1777
|
+
failIfIngressInDocument(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
|
|
941
1778
|
}
|
|
942
1779
|
}
|
|
943
1780
|
}
|
|
944
1781
|
}
|
|
945
1782
|
|
|
1783
|
+
/**
|
|
1784
|
+
* Перевірка поля **resources** для одного контейнера **Deployment**.
|
|
1785
|
+
* @param {unknown} c елемент **containers[]**
|
|
1786
|
+
* @param {string} label підпис у повідомленні
|
|
1787
|
+
* @returns {string | null} текст порушення або null
|
|
1788
|
+
*/
|
|
1789
|
+
function deploymentContainerResourcesViolation(c, label) {
|
|
1790
|
+
if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
|
|
1791
|
+
return null
|
|
1792
|
+
}
|
|
1793
|
+
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
1794
|
+
if (!('resources' in cont)) {
|
|
1795
|
+
return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
|
|
1796
|
+
}
|
|
1797
|
+
const r = cont.resources
|
|
1798
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
1799
|
+
return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
|
|
1800
|
+
}
|
|
1801
|
+
return null
|
|
1802
|
+
}
|
|
1803
|
+
|
|
946
1804
|
/**
|
|
947
1805
|
* Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
|
|
948
1806
|
* @param {unknown} manifest корінь YAML-документа як запис JavaScript
|
|
@@ -968,15 +1826,9 @@ export function deploymentResourcesViolation(manifest) {
|
|
|
968
1826
|
typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
|
|
969
1827
|
? c.name
|
|
970
1828
|
: `#${i + 1}`
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
|
|
975
|
-
}
|
|
976
|
-
const r = cont.resources
|
|
977
|
-
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
978
|
-
return `контейнер "${label}": resources має бути записом у YAML (наприклад порожній: resources: {})`
|
|
979
|
-
}
|
|
1829
|
+
const v = deploymentContainerResourcesViolation(c, label)
|
|
1830
|
+
if (v !== null) {
|
|
1831
|
+
return v
|
|
980
1832
|
}
|
|
981
1833
|
}
|
|
982
1834
|
|
|
@@ -1000,31 +1852,48 @@ function stripImageDigest(image) {
|
|
|
1000
1852
|
*/
|
|
1001
1853
|
function isHasuraGraphqlEngineImageRef(image) {
|
|
1002
1854
|
const s = stripImageDigest(image)
|
|
1003
|
-
return
|
|
1855
|
+
return HASURA_GRAPHQL_ENGINE_RE.test(s)
|
|
1004
1856
|
}
|
|
1005
1857
|
|
|
1006
1858
|
/**
|
|
1007
|
-
*
|
|
1859
|
+
* Перевірка образу Hasura для одного контейнера у списку **containers** / **initContainers**.
|
|
1008
1860
|
* @param {string} list ім’я поля для повідомлення (`containers` / `initContainers`)
|
|
1009
|
-
* @param {unknown}
|
|
1861
|
+
* @param {unknown} c елемент масиву
|
|
1862
|
+
* @param {number} i індекс
|
|
1863
|
+
* @returns {string | null} текст порушення або null
|
|
1864
|
+
*/
|
|
1865
|
+
function hasuraGraphqlEngineViolationForOneContainer(list, c, i) {
|
|
1866
|
+
const label =
|
|
1867
|
+
typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
|
|
1868
|
+
? c.name
|
|
1869
|
+
: `#${i + 1}`
|
|
1870
|
+
if (c === null || c === undefined || typeof c !== 'object' || Array.isArray(c)) {
|
|
1871
|
+
return null
|
|
1872
|
+
}
|
|
1873
|
+
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
1874
|
+
const image = cont.image
|
|
1875
|
+
if (typeof image !== 'string' || image.trim() === '' || !isHasuraGraphqlEngineImageRef(image)) {
|
|
1876
|
+
return null
|
|
1877
|
+
}
|
|
1878
|
+
const normalized = stripImageDigest(image)
|
|
1879
|
+
if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
|
|
1880
|
+
return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
|
|
1881
|
+
}
|
|
1882
|
+
return null
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Перевіряє масив **containers** / **initContainers** на зафіксований образ Hasura.
|
|
1887
|
+
* @param {string} list **containers** або **initContainers** (для тексту помилки)
|
|
1888
|
+
* @param {unknown} containers значення поля з маніфесту
|
|
1010
1889
|
* @returns {string | null} текст порушення або null
|
|
1011
1890
|
*/
|
|
1012
1891
|
function hasuraGraphqlEngineViolationInContainerList(list, containers) {
|
|
1013
1892
|
if (!Array.isArray(containers)) return null
|
|
1014
1893
|
for (const [i, c] of containers.entries()) {
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
: `#${i + 1}`
|
|
1019
|
-
if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
|
|
1020
|
-
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
1021
|
-
const image = cont.image
|
|
1022
|
-
if (typeof image === 'string' && image.trim() !== '' && isHasuraGraphqlEngineImageRef(image)) {
|
|
1023
|
-
const normalized = stripImageDigest(image)
|
|
1024
|
-
if (!HASURA_GRAPHQL_ENGINE_ALLOWED_IMAGES.has(normalized)) {
|
|
1025
|
-
return `${list} "${label}": образ hasura/graphql-engine має бути ${HASURA_GRAPHQL_ENGINE_IMAGE} (зараз: ${image}) (див. k8s.mdc)`
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1894
|
+
const v = hasuraGraphqlEngineViolationForOneContainer(list, c, i)
|
|
1895
|
+
if (v !== null) {
|
|
1896
|
+
return v
|
|
1028
1897
|
}
|
|
1029
1898
|
}
|
|
1030
1899
|
return null
|
|
@@ -1230,6 +2099,35 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
|
|
|
1230
2099
|
return out
|
|
1231
2100
|
}
|
|
1232
2101
|
|
|
2102
|
+
/**
|
|
2103
|
+
* Один документ: маршрут Gateway API має посилатися на **Service** з суфіксом **`-hl`**.
|
|
2104
|
+
* @param {string} rel відносний шлях до файлу
|
|
2105
|
+
* @param {number} docIndex 1-based індекс документа
|
|
2106
|
+
* @param {Record<string, unknown>} rec корінь маніфесту
|
|
2107
|
+
* @param {(msg: string) => void} fail callback помилки
|
|
2108
|
+
* @returns {void}
|
|
2109
|
+
*/
|
|
2110
|
+
function failIfGatewayRouteUsesNonHeadlessService(rel, docIndex, rec, fail) {
|
|
2111
|
+
const av = rec.apiVersion
|
|
2112
|
+
const kind = rec.kind
|
|
2113
|
+
if (
|
|
2114
|
+
typeof av !== 'string' ||
|
|
2115
|
+
!av.startsWith(GATEWAY_API_GROUP_PREFIX) ||
|
|
2116
|
+
typeof kind !== 'string' ||
|
|
2117
|
+
!GATEWAY_API_ROUTE_KINDS.has(kind)
|
|
2118
|
+
) {
|
|
2119
|
+
return
|
|
2120
|
+
}
|
|
2121
|
+
const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
|
|
2122
|
+
for (const svcName of names) {
|
|
2123
|
+
if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
|
|
2124
|
+
fail(
|
|
2125
|
+
`${rel}: Gateway API ${kind} (документ ${docIndex}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
|
|
2126
|
+
)
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
1233
2131
|
/**
|
|
1234
2132
|
* Реєструє порушення: маршрути Gateway API мають посилатися на **Service** з суфіксом **`-hl`**.
|
|
1235
2133
|
* @param {string} rel відносний шлях до файлу
|
|
@@ -1237,40 +2135,154 @@ export function collectGatewayApiRouteBackendServiceNames(spec) {
|
|
|
1237
2135
|
* @param {(msg: string) => void} fail callback помилки
|
|
1238
2136
|
* @returns {void}
|
|
1239
2137
|
*/
|
|
1240
|
-
function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
|
|
1241
|
-
/** @type {import('yaml').Document[]} */
|
|
1242
|
-
let docs
|
|
2138
|
+
function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
|
|
2139
|
+
/** @type {import('yaml').Document[]} */
|
|
2140
|
+
let docs
|
|
2141
|
+
try {
|
|
2142
|
+
docs = parseAllDocuments(body)
|
|
2143
|
+
} catch {
|
|
2144
|
+
return
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
for (const [di, doc] of docs.entries()) {
|
|
2148
|
+
if (doc.errors.length === 0) {
|
|
2149
|
+
const obj = doc.toJSON()
|
|
2150
|
+
if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
|
|
2151
|
+
failIfGatewayRouteUsesNonHeadlessService(rel, di + 1, /** @type {Record<string, unknown>} */ (obj), fail)
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
/**
|
|
2158
|
+
* Збирає **`metadata.name`** для **kind: Service** у коренях документів; при помилці викликає **fail** і повертає false.
|
|
2159
|
+
* @param {Record<string, unknown>[]} roots корені YAML-документів
|
|
2160
|
+
* @param {string} relForMsg відносний шлях до файлу для повідомлення
|
|
2161
|
+
* @param {string} fileLabel **svc.yaml** / **svc-hl.yaml**
|
|
2162
|
+
* @param {string[]} names накопичувач імен
|
|
2163
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2164
|
+
* @returns {boolean} false, якщо зафіксовано порушення
|
|
2165
|
+
*/
|
|
2166
|
+
function appendServiceNamesFromSvcRoots(roots, relForMsg, fileLabel, names, fail) {
|
|
2167
|
+
for (const [i, rootObj] of roots.entries()) {
|
|
2168
|
+
const r = /** @type {Record<string, unknown>} */ (rootObj)
|
|
2169
|
+
if (r.kind === 'Service') {
|
|
2170
|
+
const meta = r.metadata
|
|
2171
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
|
|
2172
|
+
fail(`${relForMsg}: ${fileLabel} (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
|
|
2173
|
+
return false
|
|
2174
|
+
}
|
|
2175
|
+
const nm = /** @type {Record<string, unknown>} */ (meta).name
|
|
2176
|
+
if (typeof nm !== 'string') {
|
|
2177
|
+
fail(`${relForMsg}: ${fileLabel} (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
|
|
2178
|
+
return false
|
|
2179
|
+
}
|
|
2180
|
+
names.push(nm)
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
return true
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
/**
|
|
2187
|
+
* Узгодженість імен **Service** між **svc.yaml** та **svc-hl.yaml**.
|
|
2188
|
+
* @param {string} relSvc відносний шлях до **svc.yaml**
|
|
2189
|
+
* @param {string} relHl відносний шлях до **svc-hl.yaml**
|
|
2190
|
+
* @param {string[]} svcNames імена з **svc.yaml**
|
|
2191
|
+
* @param {string[]} hlNames імена з **svc-hl.yaml**
|
|
2192
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2193
|
+
* @returns {void}
|
|
2194
|
+
*/
|
|
2195
|
+
function validateSvcHlServiceNamePairing(relSvc, relHl, svcNames, hlNames, fail) {
|
|
2196
|
+
if (svcNames.length === 0) {
|
|
2197
|
+
fail(`${relSvc}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
|
|
2198
|
+
return
|
|
2199
|
+
}
|
|
2200
|
+
if (hlNames.length === 0) {
|
|
2201
|
+
fail(`${relHl}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
|
|
2202
|
+
return
|
|
2203
|
+
}
|
|
2204
|
+
const hlSet = new Set(hlNames)
|
|
2205
|
+
for (const n of svcNames) {
|
|
2206
|
+
const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
|
|
2207
|
+
if (!hlSet.has(expectHl)) {
|
|
2208
|
+
fail(
|
|
2209
|
+
`${relSvc}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
|
|
2210
|
+
)
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
for (const h of hlNames) {
|
|
2214
|
+
if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
|
|
2215
|
+
const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
|
|
2216
|
+
if (!svcNames.includes(base)) {
|
|
2217
|
+
fail(
|
|
2218
|
+
`${relHl}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
|
|
2219
|
+
)
|
|
2220
|
+
}
|
|
2221
|
+
} else {
|
|
2222
|
+
fail(
|
|
2223
|
+
`${relHl}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
|
|
2224
|
+
)
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
/**
|
|
2230
|
+
* **svc-hl.yaml** без **svc.yaml** у тому самому каталозі.
|
|
2231
|
+
* @param {string} root корінь репозиторію
|
|
2232
|
+
* @param {string[]} yamlFiles абсолютні шляхи
|
|
2233
|
+
* @param {Set<string>} absSet той самий набір шляхів
|
|
2234
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2235
|
+
* @returns {void}
|
|
2236
|
+
*/
|
|
2237
|
+
function failIfSvcHlWithoutSiblingSvc(root, yamlFiles, absSet, fail) {
|
|
2238
|
+
for (const abs of yamlFiles.filter(p => basename(p).toLowerCase() === 'svc-hl.yaml')) {
|
|
2239
|
+
const svcAbs = join(dirname(abs), 'svc.yaml')
|
|
2240
|
+
if (!absSet.has(svcAbs)) {
|
|
2241
|
+
const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
|
|
2242
|
+
fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
/**
|
|
2248
|
+
* Одна пара **svc.yaml** / **svc-hl.yaml**: читання, імена **Service**, узгодженість.
|
|
2249
|
+
* @param {string} root корінь репозиторію
|
|
2250
|
+
* @param {Set<string>} absSet наявні yaml під k8s
|
|
2251
|
+
* @param {string} svcAbs абсолютний шлях до **svc.yaml**
|
|
2252
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2253
|
+
* @returns {Promise<void>}
|
|
2254
|
+
*/
|
|
2255
|
+
async function validateOneSvcYamlHlPair(root, absSet, svcAbs, fail) {
|
|
2256
|
+
const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
|
|
2257
|
+
const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
|
|
2258
|
+
if (!absSet.has(hlAbs)) {
|
|
2259
|
+
fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
|
|
2260
|
+
return
|
|
2261
|
+
}
|
|
2262
|
+
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
2263
|
+
let svcBody
|
|
2264
|
+
let hlBody
|
|
1243
2265
|
try {
|
|
1244
|
-
|
|
1245
|
-
|
|
2266
|
+
svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
|
|
2267
|
+
hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
|
|
2268
|
+
} catch (error) {
|
|
2269
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
2270
|
+
fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
|
|
1246
2271
|
return
|
|
1247
2272
|
}
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
const av = rec.apiVersion
|
|
1255
|
-
const kind = rec.kind
|
|
1256
|
-
if (
|
|
1257
|
-
typeof av === 'string' &&
|
|
1258
|
-
av.startsWith(GATEWAY_API_GROUP_PREFIX) &&
|
|
1259
|
-
typeof kind === 'string' &&
|
|
1260
|
-
GATEWAY_API_ROUTE_KINDS.has(kind)
|
|
1261
|
-
) {
|
|
1262
|
-
const names = collectGatewayApiRouteBackendServiceNames(rec.spec)
|
|
1263
|
-
for (const svcName of names) {
|
|
1264
|
-
if (!svcName.endsWith(SVC_HL_NAME_SUFFIX)) {
|
|
1265
|
-
fail(
|
|
1266
|
-
`${rel}: Gateway API ${kind} (документ ${di + 1}): backendRef до Service має вказувати headless-сервіс з суфіксом «${SVC_HL_NAME_SUFFIX}» у name (зараз: «${svcName}»; див. k8s.mdc)`
|
|
1267
|
-
)
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
}
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
2273
|
+
const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
|
|
2274
|
+
const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
|
|
2275
|
+
/** @type {string[]} */
|
|
2276
|
+
const svcNames = []
|
|
2277
|
+
if (!appendServiceNamesFromSvcRoots(svcRoots, rel, 'svc.yaml', svcNames, fail)) {
|
|
2278
|
+
return
|
|
1273
2279
|
}
|
|
2280
|
+
/** @type {string[]} */
|
|
2281
|
+
const hlNames = []
|
|
2282
|
+
if (!appendServiceNamesFromSvcRoots(hlRoots, hlRel, 'svc-hl.yaml', hlNames, fail)) {
|
|
2283
|
+
return
|
|
2284
|
+
}
|
|
2285
|
+
validateSvcHlServiceNamePairing(rel, hlRel, svcNames, hlNames, fail)
|
|
1274
2286
|
}
|
|
1275
2287
|
|
|
1276
2288
|
/**
|
|
@@ -1282,117 +2294,9 @@ function scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail) {
|
|
|
1282
2294
|
*/
|
|
1283
2295
|
async function validateSvcYamlAndSvcHlPairs(root, yamlFiles, fail) {
|
|
1284
2296
|
const absSet = new Set(yamlFiles)
|
|
1285
|
-
|
|
1286
|
-
for (const
|
|
1287
|
-
|
|
1288
|
-
const svcAbs = join(dirname(abs), 'svc.yaml')
|
|
1289
|
-
if (!absSet.has(svcAbs)) {
|
|
1290
|
-
const rel = (relative(root, abs) || abs).replaceAll('\\', '/')
|
|
1291
|
-
fail(`${rel}: svc-hl.yaml потребує svc.yaml у тому самому каталозі (див. k8s.mdc)`)
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
for (const svcAbs of yamlFiles) {
|
|
1297
|
-
if (basename(svcAbs).toLowerCase() === 'svc.yaml') {
|
|
1298
|
-
const rel = (relative(root, svcAbs) || svcAbs).replaceAll('\\', '/')
|
|
1299
|
-
const hlAbs = join(dirname(svcAbs), 'svc-hl.yaml')
|
|
1300
|
-
if (absSet.has(hlAbs)) {
|
|
1301
|
-
/** @type {string | undefined} */
|
|
1302
|
-
let svcBody
|
|
1303
|
-
/** @type {string | undefined} */
|
|
1304
|
-
let hlBody
|
|
1305
|
-
try {
|
|
1306
|
-
svcBody = await readK8sYamlBodyAfterModelineForSvcPair(svcAbs)
|
|
1307
|
-
hlBody = await readK8sYamlBodyAfterModelineForSvcPair(hlAbs)
|
|
1308
|
-
} catch (error) {
|
|
1309
|
-
const msg = error instanceof Error ? error.message : String(error)
|
|
1310
|
-
fail(`${rel}: не вдалося прочитати svc.yaml / svc-hl.yaml (${msg})`)
|
|
1311
|
-
}
|
|
1312
|
-
if (svcBody !== undefined && hlBody !== undefined) {
|
|
1313
|
-
const svcRoots = parseK8sYamlDocumentObjectRoots(svcBody)
|
|
1314
|
-
const hlRoots = parseK8sYamlDocumentObjectRoots(hlBody)
|
|
1315
|
-
|
|
1316
|
-
/** @type {string[]} */
|
|
1317
|
-
const svcNames = []
|
|
1318
|
-
for (const [i, rootObj] of svcRoots.entries()) {
|
|
1319
|
-
const r = /** @type {Record<string, unknown>} */ (rootObj)
|
|
1320
|
-
if (r.kind === 'Service') {
|
|
1321
|
-
const meta = r.metadata
|
|
1322
|
-
if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
|
|
1323
|
-
const nm = /** @type {Record<string, unknown>} */ (meta).name
|
|
1324
|
-
if (typeof nm === 'string') {
|
|
1325
|
-
svcNames.push(nm)
|
|
1326
|
-
} else {
|
|
1327
|
-
fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
|
|
1328
|
-
}
|
|
1329
|
-
} else {
|
|
1330
|
-
fail(`${rel}: svc.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
if (svcNames.length === 0) {
|
|
1336
|
-
fail(`${rel}: svc.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
|
|
1337
|
-
} else {
|
|
1338
|
-
/** @type {string[]} */
|
|
1339
|
-
const hlNames = []
|
|
1340
|
-
for (const [i, rootObj] of hlRoots.entries()) {
|
|
1341
|
-
const r = /** @type {Record<string, unknown>} */ (rootObj)
|
|
1342
|
-
if (r.kind === 'Service') {
|
|
1343
|
-
const meta = r.metadata
|
|
1344
|
-
if (meta !== null && typeof meta === 'object' && !Array.isArray(meta)) {
|
|
1345
|
-
const nm = /** @type {Record<string, unknown>} */ (meta).name
|
|
1346
|
-
if (typeof nm === 'string') {
|
|
1347
|
-
hlNames.push(nm)
|
|
1348
|
-
} else {
|
|
1349
|
-
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
1350
|
-
fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata.name (див. k8s.mdc)`)
|
|
1351
|
-
}
|
|
1352
|
-
} else {
|
|
1353
|
-
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
1354
|
-
fail(`${hlRel}: svc-hl.yaml (документ ${i + 1}): Service без metadata (див. k8s.mdc)`)
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
}
|
|
1358
|
-
|
|
1359
|
-
if (hlNames.length === 0) {
|
|
1360
|
-
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
1361
|
-
fail(`${hlRel}: svc-hl.yaml має містити принаймні один kind: Service (див. k8s.mdc)`)
|
|
1362
|
-
} else {
|
|
1363
|
-
const hlSet = new Set(hlNames)
|
|
1364
|
-
for (const n of svcNames) {
|
|
1365
|
-
const expectHl = `${n}${SVC_HL_NAME_SUFFIX}`
|
|
1366
|
-
if (!hlSet.has(expectHl)) {
|
|
1367
|
-
fail(
|
|
1368
|
-
`${rel}: для Service «${n}» у svc.yaml у svc-hl.yaml має бути Service з metadata.name «${expectHl}» (див. k8s.mdc)`
|
|
1369
|
-
)
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
for (const h of hlNames) {
|
|
1374
|
-
if (h.endsWith(SVC_HL_NAME_SUFFIX)) {
|
|
1375
|
-
const base = h.slice(0, -SVC_HL_NAME_SUFFIX.length)
|
|
1376
|
-
if (!svcNames.includes(base)) {
|
|
1377
|
-
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
1378
|
-
fail(
|
|
1379
|
-
`${hlRel}: Service «${h}» у svc-hl.yaml не відповідає жодному Service у svc.yaml (очікується базове ім’я «${base}»; див. k8s.mdc)`
|
|
1380
|
-
)
|
|
1381
|
-
}
|
|
1382
|
-
} else {
|
|
1383
|
-
const hlRel = (relative(root, hlAbs) || hlAbs).replaceAll('\\', '/')
|
|
1384
|
-
fail(
|
|
1385
|
-
`${hlRel}: Service «${h}» у svc-hl.yaml: metadata.name має закінчуватися на «${SVC_HL_NAME_SUFFIX}» (див. k8s.mdc)`
|
|
1386
|
-
)
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
} else {
|
|
1393
|
-
fail(`${rel}: поруч обов’язковий svc-hl.yaml (headless-копія з суфіксом -hl у metadata.name; див. k8s.mdc)`)
|
|
1394
|
-
}
|
|
1395
|
-
}
|
|
2297
|
+
failIfSvcHlWithoutSiblingSvc(root, yamlFiles, absSet, fail)
|
|
2298
|
+
for (const svcAbs of yamlFiles.filter(p => basename(p).toLowerCase() === 'svc.yaml')) {
|
|
2299
|
+
await validateOneSvcYamlHlPair(root, absSet, svcAbs, fail)
|
|
1396
2300
|
}
|
|
1397
2301
|
}
|
|
1398
2302
|
|
|
@@ -1460,7 +2364,90 @@ function isKustomizationFileName(baseLower) {
|
|
|
1460
2364
|
export function isK8sBaseManifestYamlPath(rel, baseLower) {
|
|
1461
2365
|
if (isKustomizationFileName(baseLower)) return false
|
|
1462
2366
|
const n = rel.replaceAll('\\', '/')
|
|
1463
|
-
return
|
|
2367
|
+
return K8S_BASE_SEGMENT_RE.test(n)
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/**
|
|
2371
|
+
* Правила **metadata.namespace** для одного документа.
|
|
2372
|
+
* @param {string} rel відносний шлях
|
|
2373
|
+
* @param {number} docIndex 1-based
|
|
2374
|
+
* @param {unknown} obj корінь документа
|
|
2375
|
+
* @param {boolean} skipMetaNs пропуск для **kustomization.yaml**
|
|
2376
|
+
* @param {boolean} inBaseManifest файл у **k8s/base/**
|
|
2377
|
+
* @param {boolean} kustomizeManaged файл у графі kustomization
|
|
2378
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2379
|
+
* @returns {void}
|
|
2380
|
+
*/
|
|
2381
|
+
function failIfK8sPolicyNamespaceRulesViolated(
|
|
2382
|
+
rel,
|
|
2383
|
+
docIndex,
|
|
2384
|
+
obj,
|
|
2385
|
+
skipMetaNs,
|
|
2386
|
+
inBaseManifest,
|
|
2387
|
+
kustomizeManaged,
|
|
2388
|
+
fail
|
|
2389
|
+
) {
|
|
2390
|
+
if (skipMetaNs) {
|
|
2391
|
+
return
|
|
2392
|
+
}
|
|
2393
|
+
if (inBaseManifest) {
|
|
2394
|
+
const req = metadataNamespaceRequiredViolation(obj, true)
|
|
2395
|
+
if (req !== null) {
|
|
2396
|
+
fail(`${rel}: документ ${docIndex}: ${req}`)
|
|
2397
|
+
}
|
|
2398
|
+
return
|
|
2399
|
+
}
|
|
2400
|
+
if (kustomizeManaged) {
|
|
2401
|
+
const ns = metadataNamespaceForbiddenViolation(obj)
|
|
2402
|
+
if (ns !== null) {
|
|
2403
|
+
fail(`${rel}: документ ${docIndex}: ${ns}`)
|
|
2404
|
+
}
|
|
2405
|
+
return
|
|
2406
|
+
}
|
|
2407
|
+
const req = metadataNamespaceRequiredViolation(obj, false)
|
|
2408
|
+
if (req !== null) {
|
|
2409
|
+
fail(`${rel}: документ ${docIndex}: ${req}`)
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* Deployment / Service / HealthCheckPolicy — політики для одного документа.
|
|
2415
|
+
* @param {string} rel відносний шлях
|
|
2416
|
+
* @param {string} baseLower basename (нижній регістр)
|
|
2417
|
+
* @param {number} docIndex 1-based
|
|
2418
|
+
* @param {unknown} obj корінь документа
|
|
2419
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2420
|
+
* @returns {void}
|
|
2421
|
+
*/
|
|
2422
|
+
function failIfK8sPolicyResourceRulesViolated(rel, baseLower, docIndex, obj, fail) {
|
|
2423
|
+
const resV = deploymentResourcesViolation(obj)
|
|
2424
|
+
if (resV !== null) {
|
|
2425
|
+
fail(`${rel}: Deployment (документ ${docIndex}): ${resV}`)
|
|
2426
|
+
}
|
|
2427
|
+
const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
|
|
2428
|
+
if (hasuraV !== null) {
|
|
2429
|
+
fail(`${rel}: Deployment (документ ${docIndex}): ${hasuraV}`)
|
|
2430
|
+
}
|
|
2431
|
+
const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
|
|
2432
|
+
if (svcGcpV !== null) {
|
|
2433
|
+
fail(`${rel}: Service (документ ${docIndex}): ${svcGcpV}`)
|
|
2434
|
+
}
|
|
2435
|
+
if (baseLower === 'svc.yaml') {
|
|
2436
|
+
const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
|
|
2437
|
+
if (svcT !== null) {
|
|
2438
|
+
fail(`${rel}: Service (документ ${docIndex}): ${svcT}`)
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (baseLower === 'svc-hl.yaml') {
|
|
2442
|
+
const svcH = serviceSvcHlYamlHeadlessViolation(obj)
|
|
2443
|
+
if (svcH !== null) {
|
|
2444
|
+
fail(`${rel}: Service (документ ${docIndex}): ${svcH}`)
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
|
|
2448
|
+
if (hcpHl !== null) {
|
|
2449
|
+
fail(`${rel}: документ ${docIndex}: ${hcpHl}`)
|
|
2450
|
+
}
|
|
1464
2451
|
}
|
|
1465
2452
|
|
|
1466
2453
|
/**
|
|
@@ -1492,52 +2479,16 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
|
|
|
1492
2479
|
fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
|
|
1493
2480
|
} else {
|
|
1494
2481
|
const obj = doc.toJSON()
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
}
|
|
1506
|
-
} else {
|
|
1507
|
-
const req = metadataNamespaceRequiredViolation(obj, false)
|
|
1508
|
-
if (req !== null) {
|
|
1509
|
-
fail(`${rel}: документ ${di + 1}: ${req}`)
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
|
-
const resV = deploymentResourcesViolation(obj)
|
|
1514
|
-
if (resV !== null) {
|
|
1515
|
-
fail(`${rel}: Deployment (документ ${di + 1}): ${resV}`)
|
|
1516
|
-
}
|
|
1517
|
-
const hasuraV = deploymentHasuraGraphqlEngineImageViolation(obj)
|
|
1518
|
-
if (hasuraV !== null) {
|
|
1519
|
-
fail(`${rel}: Deployment (документ ${di + 1}): ${hasuraV}`)
|
|
1520
|
-
}
|
|
1521
|
-
const svcGcpV = serviceForbiddenGcpAnnotationsViolation(obj)
|
|
1522
|
-
if (svcGcpV !== null) {
|
|
1523
|
-
fail(`${rel}: Service (документ ${di + 1}): ${svcGcpV}`)
|
|
1524
|
-
}
|
|
1525
|
-
if (baseLower === 'svc.yaml') {
|
|
1526
|
-
const svcT = serviceSvcYamlClusterIpTypeViolation(obj)
|
|
1527
|
-
if (svcT !== null) {
|
|
1528
|
-
fail(`${rel}: Service (документ ${di + 1}): ${svcT}`)
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
if (baseLower === 'svc-hl.yaml') {
|
|
1532
|
-
const svcH = serviceSvcHlYamlHeadlessViolation(obj)
|
|
1533
|
-
if (svcH !== null) {
|
|
1534
|
-
fail(`${rel}: Service (документ ${di + 1}): ${svcH}`)
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
const hcpHl = healthCheckPolicyTargetRefHeadlessServiceViolation(obj)
|
|
1538
|
-
if (hcpHl !== null) {
|
|
1539
|
-
fail(`${rel}: документ ${di + 1}: ${hcpHl}`)
|
|
1540
|
-
}
|
|
2482
|
+
failIfK8sPolicyNamespaceRulesViolated(
|
|
2483
|
+
rel,
|
|
2484
|
+
di + 1,
|
|
2485
|
+
obj,
|
|
2486
|
+
skipMetaNs,
|
|
2487
|
+
inBaseManifest,
|
|
2488
|
+
kustomizeManaged,
|
|
2489
|
+
fail
|
|
2490
|
+
)
|
|
2491
|
+
failIfK8sPolicyResourceRulesViolated(rel, baseLower, di + 1, obj, fail)
|
|
1541
2492
|
}
|
|
1542
2493
|
}
|
|
1543
2494
|
}
|
|
@@ -1548,31 +2499,24 @@ function validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeMan
|
|
|
1548
2499
|
* @returns {string} рядок для шаблону імені файлу схеми
|
|
1549
2500
|
*/
|
|
1550
2501
|
function kindToSchemaFilePart(kind) {
|
|
1551
|
-
|
|
2502
|
+
let out = ''
|
|
2503
|
+
for (const ch of kind) {
|
|
2504
|
+
const c = ch.codePointAt(0)
|
|
2505
|
+
if (c !== undefined && ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122))) {
|
|
2506
|
+
out += ch
|
|
2507
|
+
}
|
|
2508
|
+
}
|
|
2509
|
+
return out.toLowerCase()
|
|
1552
2510
|
}
|
|
1553
2511
|
|
|
1554
2512
|
/**
|
|
1555
|
-
* Очікуваний
|
|
1556
|
-
* @param {string}
|
|
1557
|
-
* @param {string}
|
|
1558
|
-
* @
|
|
2513
|
+
* Очікуваний URL схеми за **apiVersion/kind** (не **kustomization.yaml**).
|
|
2514
|
+
* @param {string} doc текст першого документа
|
|
2515
|
+
* @param {string} apiVersion значення **apiVersion** з маніфесту
|
|
2516
|
+
* @param {string} kind значення **kind** з маніфесту
|
|
2517
|
+
* @returns {{ expected: string | null, reason: string }} очікуваний URL і пояснення для повідомлень
|
|
1559
2518
|
*/
|
|
1560
|
-
|
|
1561
|
-
const base = basename(filePath)
|
|
1562
|
-
const baseLower = base.toLowerCase()
|
|
1563
|
-
|
|
1564
|
-
if (baseLower === 'kustomization.yaml') {
|
|
1565
|
-
return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
const { apiVersion, kind } = extractApiVersionAndKind(doc)
|
|
1569
|
-
if (!apiVersion || !kind) {
|
|
1570
|
-
return {
|
|
1571
|
-
expected: null,
|
|
1572
|
-
reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
|
|
1573
|
-
}
|
|
1574
|
-
}
|
|
1575
|
-
|
|
2519
|
+
function expectedSchemaUrlForTypedManifest(doc, apiVersion, kind) {
|
|
1576
2520
|
const manifestType = extractTopLevelManifestType(doc)
|
|
1577
2521
|
const explicit = lookupExplicitK8sSchema(apiVersion, kind, manifestType)
|
|
1578
2522
|
if (explicit) {
|
|
@@ -1608,13 +2552,122 @@ export function expectedSchemaUrl(filePath, doc) {
|
|
|
1608
2552
|
return { expected: url, reason: 'CRD / група поза yannh (datree CRDs-catalog)' }
|
|
1609
2553
|
}
|
|
1610
2554
|
|
|
2555
|
+
/**
|
|
2556
|
+
* Очікуваний $schema для маніфесту згідно з k8s.mdc.
|
|
2557
|
+
* @param {string} filePath шлях до файлу (для імені kustomization)
|
|
2558
|
+
* @param {string} doc перший YAML-документ після modeline
|
|
2559
|
+
* @returns {{ expected: string | null, reason: string }} reason — для повідомлень про помилку
|
|
2560
|
+
*/
|
|
2561
|
+
export function expectedSchemaUrl(filePath, doc) {
|
|
2562
|
+
const base = basename(filePath)
|
|
2563
|
+
const baseLower = base.toLowerCase()
|
|
2564
|
+
|
|
2565
|
+
if (baseLower === 'kustomization.yaml') {
|
|
2566
|
+
return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
const { apiVersion, kind } = extractApiVersionAndKind(doc)
|
|
2570
|
+
if (!apiVersion || !kind) {
|
|
2571
|
+
return {
|
|
2572
|
+
expected: null,
|
|
2573
|
+
reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
return expectedSchemaUrlForTypedManifest(doc, apiVersion, kind)
|
|
2578
|
+
}
|
|
2579
|
+
|
|
1611
2580
|
/**
|
|
1612
2581
|
* Підраховує рядки з modeline $schema у файлі.
|
|
1613
2582
|
* @param {string[]} lines рядки файлу
|
|
1614
2583
|
* @returns {number} скільки рядків містять modeline `$schema`
|
|
1615
2584
|
*/
|
|
1616
2585
|
function countSchemaModelines(lines) {
|
|
1617
|
-
return lines.filter(l =>
|
|
2586
|
+
return lines.filter(l => OXLINT_SCHEMA_MODELINE_RE.test(l.trim())).length
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
/**
|
|
2590
|
+
* Політики маніфестів і Gateway backendRefs після розбору тіла.
|
|
2591
|
+
* @param {string} rel відносний шлях
|
|
2592
|
+
* @param {string} baseLower basename (нижній регістр)
|
|
2593
|
+
* @param {string} body YAML після modeline
|
|
2594
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2595
|
+
* @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
|
|
2596
|
+
* @returns {void}
|
|
2597
|
+
*/
|
|
2598
|
+
function runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel) {
|
|
2599
|
+
const kustomizeManaged = kustomizeManagedRel.has(rel)
|
|
2600
|
+
validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
|
|
2601
|
+
scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
/**
|
|
2605
|
+
* Файл з першим документом **HttpBackendGroup** (ALB Yandex): без modeline **$schema**.
|
|
2606
|
+
* @param {string} rel відносний шлях
|
|
2607
|
+
* @param {string} baseLower basename
|
|
2608
|
+
* @param {string[]} lines рядки файлу
|
|
2609
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2610
|
+
* @param {(msg: string) => void} pass реєстрація успіху
|
|
2611
|
+
* @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
|
|
2612
|
+
* @returns {void}
|
|
2613
|
+
*/
|
|
2614
|
+
function checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
|
|
2615
|
+
const body = lines.join('\n')
|
|
2616
|
+
scanIngressInYamlDocuments(rel, body, fail)
|
|
2617
|
+
pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
|
|
2618
|
+
runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
/**
|
|
2622
|
+
* Стандартний файл: перший рядок — modeline **$schema**, далі перевірка URL і політики.
|
|
2623
|
+
* @param {string} abs абсолютний шлях
|
|
2624
|
+
* @param {string} rel відносний шлях
|
|
2625
|
+
* @param {string} baseLower basename
|
|
2626
|
+
* @param {string[]} lines рядки файлу
|
|
2627
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
2628
|
+
* @param {(msg: string) => void} pass реєстрація успіху
|
|
2629
|
+
* @param {Set<string>} kustomizeManagedRel kustomize-managed шляхи
|
|
2630
|
+
* @returns {void}
|
|
2631
|
+
*/
|
|
2632
|
+
function checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel) {
|
|
2633
|
+
const match = lines[0].match(MODELINE_RE)
|
|
2634
|
+
if (!match) {
|
|
2635
|
+
fail(`${rel}: некоректний modeline $schema у першому рядку`)
|
|
2636
|
+
return
|
|
2637
|
+
}
|
|
2638
|
+
const schemaUrl = match[1]
|
|
2639
|
+
if (countSchemaModelines(lines) > 1) {
|
|
2640
|
+
fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
|
|
2641
|
+
return
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2644
|
+
const body = yamlBodyAfterModeline(lines)
|
|
2645
|
+
|
|
2646
|
+
scanIngressInYamlDocuments(rel, body, fail)
|
|
2647
|
+
|
|
2648
|
+
if (schemaUrl.startsWith('file:')) {
|
|
2649
|
+
pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
|
|
2650
|
+
} else if (HTTPS_SCHEMA_RE.test(schemaUrl)) {
|
|
2651
|
+
const doc = firstYamlDocument(body)
|
|
2652
|
+
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
2653
|
+
|
|
2654
|
+
if (expected === null) {
|
|
2655
|
+
fail(`${rel}: ${reason}`)
|
|
2656
|
+
return
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
if (schemaUrl !== expected) {
|
|
2660
|
+
fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
|
|
2661
|
+
return
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
pass(`${rel}: $schema узгоджено (${reason})`)
|
|
2665
|
+
} else {
|
|
2666
|
+
fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
|
|
2667
|
+
return
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
runK8sYamlPolicyAndGatewayScans(rel, baseLower, body, fail, kustomizeManagedRel)
|
|
1618
2671
|
}
|
|
1619
2672
|
|
|
1620
2673
|
/**
|
|
@@ -1667,12 +2720,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
|
|
|
1667
2720
|
)
|
|
1668
2721
|
return
|
|
1669
2722
|
}
|
|
1670
|
-
|
|
1671
|
-
scanIngressInYamlDocuments(rel, body, fail)
|
|
1672
|
-
pass(`${rel}: HttpBackendGroup (alb.yc.io/v1alpha1) — modeline $schema не застосовується (k8s.mdc)`)
|
|
1673
|
-
const kustomizeManaged = kustomizeManagedRel.has(rel)
|
|
1674
|
-
validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
|
|
1675
|
-
scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
|
|
2723
|
+
checkK8sYamlHttpBackendGroupFile(rel, baseLower, lines, fail, pass, kustomizeManagedRel)
|
|
1676
2724
|
return
|
|
1677
2725
|
}
|
|
1678
2726
|
|
|
@@ -1681,43 +2729,7 @@ async function checkK8sYamlFile(abs, root, fail, pass, kustomizeManagedRel) {
|
|
|
1681
2729
|
return
|
|
1682
2730
|
}
|
|
1683
2731
|
|
|
1684
|
-
|
|
1685
|
-
const schemaUrl = m[1]
|
|
1686
|
-
if (countSchemaModelines(lines) > 1) {
|
|
1687
|
-
fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
|
|
1688
|
-
return
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
const body = yamlBodyAfterModeline(lines)
|
|
1692
|
-
|
|
1693
|
-
scanIngressInYamlDocuments(rel, body, fail)
|
|
1694
|
-
|
|
1695
|
-
if (schemaUrl.startsWith('file:')) {
|
|
1696
|
-
pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
|
|
1697
|
-
} else if (/^https:/iu.test(schemaUrl)) {
|
|
1698
|
-
const doc = firstYamlDocument(body)
|
|
1699
|
-
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
1700
|
-
|
|
1701
|
-
if (expected === null) {
|
|
1702
|
-
fail(`${rel}: ${reason}`)
|
|
1703
|
-
return
|
|
1704
|
-
}
|
|
1705
|
-
|
|
1706
|
-
if (schemaUrl !== expected) {
|
|
1707
|
-
fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
|
|
1708
|
-
return
|
|
1709
|
-
}
|
|
1710
|
-
|
|
1711
|
-
pass(`${rel}: $schema узгоджено (${reason})`)
|
|
1712
|
-
} else {
|
|
1713
|
-
fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
|
|
1714
|
-
return
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
const kustomizeManaged = kustomizeManagedRel.has(rel)
|
|
1718
|
-
validateK8sYamlPolicyDocuments(rel, baseLower, body, fail, kustomizeManaged)
|
|
1719
|
-
|
|
1720
|
-
scanGatewayApiRouteBackendRefsInYamlBody(rel, body, fail)
|
|
2732
|
+
checkK8sYamlFileWithSchemaModeline(abs, rel, baseLower, lines, fail, pass, kustomizeManagedRel)
|
|
1721
2733
|
}
|
|
1722
2734
|
|
|
1723
2735
|
/**
|
|
@@ -1736,6 +2748,38 @@ function assertNoForbiddenK8sDevPaths(yamlFiles, root, fail) {
|
|
|
1736
2748
|
}
|
|
1737
2749
|
}
|
|
1738
2750
|
|
|
2751
|
+
/**
|
|
2752
|
+
* Один файл **k8s/base/kustomization.yaml**: непорожній **namespace:**.
|
|
2753
|
+
* @param {string} root корінь репозиторію
|
|
2754
|
+
* @param {string} abs абсолютний шлях до файлу
|
|
2755
|
+
* @param {(msg: string) => void} fail реєстрація порушення
|
|
2756
|
+
* @returns {Promise<void>}
|
|
2757
|
+
*/
|
|
2758
|
+
async function verifyBaseKustomizationNamespaceOnFile(root, abs, fail) {
|
|
2759
|
+
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
2760
|
+
try {
|
|
2761
|
+
const raw = await readFile(abs, 'utf8')
|
|
2762
|
+
const lines = toLines(raw)
|
|
2763
|
+
const body = yamlBodyAfterModeline(lines)
|
|
2764
|
+
/** @type {import('yaml').Document[] | undefined} */
|
|
2765
|
+
let docs
|
|
2766
|
+
try {
|
|
2767
|
+
docs = parseAllDocuments(body)
|
|
2768
|
+
} catch {
|
|
2769
|
+
fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
|
|
2770
|
+
return
|
|
2771
|
+
}
|
|
2772
|
+
const first = docs[0]?.toJSON()
|
|
2773
|
+
const v = baseKustomizationNamespaceViolation(first)
|
|
2774
|
+
if (v) {
|
|
2775
|
+
fail(`${rel}: ${v}`)
|
|
2776
|
+
}
|
|
2777
|
+
} catch (error) {
|
|
2778
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
2779
|
+
fail(`${rel}: не вдалося прочитати (${msg})`)
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
|
|
1739
2783
|
/**
|
|
1740
2784
|
* Якщо є **`k8s/base/kustomization.yaml`**, у ньому **завжди** має бути непорожній **`namespace:`**.
|
|
1741
2785
|
* @param {string} root корінь репозиторію
|
|
@@ -1747,28 +2791,7 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
|
|
|
1747
2791
|
for (const abs of yamlFiles) {
|
|
1748
2792
|
const rel = relative(root, abs).replaceAll('\\', '/')
|
|
1749
2793
|
if (isBaseKustomizationPath(rel)) {
|
|
1750
|
-
|
|
1751
|
-
const raw = await readFile(abs, 'utf8')
|
|
1752
|
-
const lines = toLines(raw)
|
|
1753
|
-
const body = yamlBodyAfterModeline(lines)
|
|
1754
|
-
/** @type {import('yaml').Document[] | undefined} */
|
|
1755
|
-
let docs
|
|
1756
|
-
try {
|
|
1757
|
-
docs = parseAllDocuments(body)
|
|
1758
|
-
} catch {
|
|
1759
|
-
fail(`${rel}: не вдалося розпарсити YAML для перевірки namespace у base (див. k8s.mdc)`)
|
|
1760
|
-
}
|
|
1761
|
-
if (docs !== undefined) {
|
|
1762
|
-
const first = docs[0]?.toJSON()
|
|
1763
|
-
const v = baseKustomizationNamespaceViolation(first)
|
|
1764
|
-
if (v) {
|
|
1765
|
-
fail(`${rel}: ${v}`)
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
} catch (error) {
|
|
1769
|
-
const msg = error instanceof Error ? error.message : String(error)
|
|
1770
|
-
fail(`${rel}: не вдалося прочитати (${msg})`)
|
|
1771
|
-
}
|
|
2794
|
+
await verifyBaseKustomizationNamespaceOnFile(root, abs, fail)
|
|
1772
2795
|
}
|
|
1773
2796
|
}
|
|
1774
2797
|
}
|
|
@@ -1808,6 +2831,8 @@ export async function check() {
|
|
|
1808
2831
|
|
|
1809
2832
|
await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
|
|
1810
2833
|
|
|
2834
|
+
await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
|
|
2835
|
+
|
|
1811
2836
|
await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
|
|
1812
2837
|
|
|
1813
2838
|
return reporter.getExitCode()
|