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