@nitra/cursor 1.8.124 → 1.8.127
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 +1 -1
- package/mdc/docker.mdc +10 -1
- package/mdc/ga.mdc +28 -0
- package/mdc/k8s.mdc +6 -2
- package/package.json +1 -1
- package/scripts/check-docker.mjs +80 -0
- package/scripts/check-ga.mjs +72 -0
- package/scripts/check-k8s.mjs +393 -0
- package/scripts/utils/docker-mirror.mjs +156 -0
- package/scripts/utils/gha-workflow.mjs +32 -0
package/mdc/abie.mdc
CHANGED
package/mdc/docker.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Dockerfile — lint-docker / hadolint; перевірка check-docker
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.8'
|
|
4
4
|
globs: "**/Dockerfile*"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -9,6 +9,15 @@ alwaysApply: false
|
|
|
9
9
|
|
|
10
10
|
[hadolint](https://github.com/hadolint/hadolint) перевіряє Dockerfile на типові помилки та рекомендації (`FROM`, `RUN`, `COPY`, shell form тощо).
|
|
11
11
|
|
|
12
|
+
Для образів з Docker Hub — **`oven/bun`**, **`alpine`**, **`nginx`**, **`node`** — у **`FROM`** треба вказувати дзеркало GCR, а не pull напряму з Hub: **`mirror.gcr.io/oven/bun`**, **`mirror.gcr.io/library/alpine`**, **`mirror.gcr.io/library/nginx`**, **`mirror.gcr.io/library/node`**. Перевіряє **`check-docker.mjs`**, деталі в **`npm/scripts/utils/docker-mirror.mjs`**.
|
|
13
|
+
|
|
14
|
+
Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
|
|
15
|
+
|
|
16
|
+
- **backend**: `mirror.gcr.io/library/alpine:*`
|
|
17
|
+
- **frontend**: `mirror.gcr.io/library/nginx:*`
|
|
18
|
+
|
|
19
|
+
Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
|
|
20
|
+
|
|
12
21
|
## Область
|
|
13
22
|
|
|
14
23
|
- Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
|
package/mdc/ga.mdc
CHANGED
|
@@ -149,8 +149,36 @@ jobs:
|
|
|
149
149
|
}
|
|
150
150
|
```
|
|
151
151
|
|
|
152
|
+
У **`.vscode/settings.json`** для мови **`github-actions-workflow`** (workflow з розширення GitHub Actions) задай **oxc** як formatter:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
"[github-actions-workflow]": {
|
|
156
|
+
"editor.defaultFormatter": "oxc.oxc-vscode"
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
152
160
|
**ЗАБОРОНЕНО** дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах. Завжди використовуй локальний composite action.
|
|
153
161
|
|
|
162
|
+
**Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
|
|
163
|
+
|
|
164
|
+
### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
|
|
165
|
+
|
|
166
|
+
```yaml
|
|
167
|
+
- run: |
|
|
168
|
+
docker build \
|
|
169
|
+
--push \
|
|
170
|
+
--build-arg BRANCH=${{ github.ref_name }}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Приклад run (ПРАВИЛЬНО — `>-`)
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
- run: >-
|
|
177
|
+
docker build
|
|
178
|
+
--push
|
|
179
|
+
--build-arg BRANCH=${{ github.ref_name }}
|
|
180
|
+
```
|
|
181
|
+
|
|
154
182
|
### Приклад (НЕПРАВИЛЬНО)
|
|
155
183
|
|
|
156
184
|
```yaml
|
package/mdc/k8s.mdc
CHANGED
|
@@ -129,7 +129,7 @@ resources:
|
|
|
129
129
|
|
|
130
130
|
```yaml
|
|
131
131
|
# yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
|
|
132
|
-
apiVersion: gateway.networking.k8s.io/
|
|
132
|
+
apiVersion: gateway.networking.k8s.io/v1
|
|
133
133
|
kind: HTTPRoute
|
|
134
134
|
metadata:
|
|
135
135
|
name: db-h
|
|
@@ -294,6 +294,10 @@ data:
|
|
|
294
294
|
- **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
|
|
295
295
|
- **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
|
|
296
296
|
|
|
297
|
+
**Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
|
|
298
|
+
|
|
299
|
+
**Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
|
|
300
|
+
|
|
297
301
|
### Env-залежні межі (за сегментом після `/k8s/`)
|
|
298
302
|
|
|
299
303
|
**Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
|
|
@@ -486,7 +490,7 @@ patches:
|
|
|
486
490
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
|
|
487
491
|
```
|
|
488
492
|
|
|
489
|
-
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/
|
|
493
|
+
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
|
|
490
494
|
|
|
491
495
|
```yaml
|
|
492
496
|
# yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
|
package/package.json
CHANGED
package/scripts/check-docker.mjs
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Запускає hadolint для Dockerfile / Containerfile у всьому репозиторії (див. docker.mdc).
|
|
3
3
|
*
|
|
4
|
+
* Додатково переконуються, що образи `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub
|
|
5
|
+
* вказуються через `mirror.gcr.io` (див. `utils/docker-mirror.mjs`).
|
|
6
|
+
*
|
|
7
|
+
* Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
|
|
8
|
+
* використовує мінімальний runtime-образ:
|
|
9
|
+
* - backend: `mirror.gcr.io/library/alpine:*`
|
|
10
|
+
* - frontend: `mirror.gcr.io/library/nginx:*`
|
|
11
|
+
*
|
|
12
|
+
* Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
|
|
13
|
+
* runtime (alpine) або nginx.
|
|
14
|
+
*
|
|
4
15
|
* Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
|
|
5
16
|
* тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
|
|
6
17
|
* Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
|
|
7
18
|
*/
|
|
19
|
+
import { readFile } from 'node:fs/promises'
|
|
8
20
|
import { basename } from 'node:path'
|
|
9
21
|
|
|
22
|
+
import { getMirrorGcrHint, getFromImageToken } from './utils/docker-mirror.mjs'
|
|
10
23
|
import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
|
|
11
24
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
12
25
|
import { walkDir } from './utils/walkDir.mjs'
|
|
13
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* line: number
|
|
30
|
+
* image: string
|
|
31
|
+
* }} FromStage
|
|
32
|
+
*/
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
|
|
16
36
|
* @param {string} name basename шляху
|
|
@@ -37,6 +57,55 @@ export async function findDockerfilePaths(root) {
|
|
|
37
57
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
38
58
|
}
|
|
39
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
64
|
+
* @returns {FromStage[]} список знайдених FROM-інструкцій
|
|
65
|
+
*/
|
|
66
|
+
export function parseFromStages(fileContent) {
|
|
67
|
+
const out = []
|
|
68
|
+
const lines = fileContent.split(/\r?\n/)
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const image = getFromImageToken(lines[i])
|
|
71
|
+
if (image) out.push({ line: i + 1, image })
|
|
72
|
+
}
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const RUNTIME_IMAGES = /** @type {const} */ ([
|
|
77
|
+
'mirror.gcr.io/library/alpine',
|
|
78
|
+
'mirror.gcr.io/library/nginx'
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Перевіряє базові вимоги до структури Dockerfile:
|
|
83
|
+
* - multistage: мінімум 2 FROM
|
|
84
|
+
* - фінальний FROM: alpine або nginx з mirror.gcr.io
|
|
85
|
+
*
|
|
86
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
87
|
+
* @returns {string | null} повідомлення помилки або null
|
|
88
|
+
*/
|
|
89
|
+
export function getMultistageAndRuntimeHint(fileContent) {
|
|
90
|
+
const stages = parseFromStages(fileContent)
|
|
91
|
+
if (stages.length === 0) return null
|
|
92
|
+
|
|
93
|
+
if (stages.length < 2) {
|
|
94
|
+
return 'має бути multistage build: мінімум 2 інструкції FROM (build stage + runtime stage)'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const last = stages.at(-1)
|
|
98
|
+
const lastImage = (last?.image || '').split('@')[0] || ''
|
|
99
|
+
const lastLower = lastImage.toLowerCase()
|
|
100
|
+
|
|
101
|
+
const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
|
|
102
|
+
if (!okRuntime) {
|
|
103
|
+
return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
40
109
|
/**
|
|
41
110
|
* Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
|
|
42
111
|
* @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
|
|
@@ -57,6 +126,17 @@ export async function check() {
|
|
|
57
126
|
|
|
58
127
|
for (const abs of files) {
|
|
59
128
|
const rel = posixRel(root, abs) || basename(abs)
|
|
129
|
+
const content = await readFile(abs, 'utf8')
|
|
130
|
+
const hint = getMirrorGcrHint(content)
|
|
131
|
+
if (hint) {
|
|
132
|
+
fail(`${rel} (mirror.gcr.io): ${hint}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const multistageHint = getMultistageAndRuntimeHint(content)
|
|
136
|
+
if (multistageHint) {
|
|
137
|
+
fail(`${rel} (multistage): ${multistageHint}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
60
140
|
const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
|
|
61
141
|
const tail = (stdout + stderr).trim()
|
|
62
142
|
if (ok) {
|
package/scripts/check-ga.mjs
CHANGED
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* Workflows лише з розширенням `.yml`, наявність clean/lint workflow, конфіг zizmor з ref-pin,
|
|
5
5
|
* відсутність MegaLinter, коректний скрипт `lint-ga` у `package.json`, виклик у `lint-ga.yml`,
|
|
6
6
|
* наявність composite `.github/actions/setup-bun-deps/action.yml` (його записує npx `\@nitra/cursor`),
|
|
7
|
+
* `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
|
|
7
8
|
* перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
|
|
8
9
|
*
|
|
9
10
|
* Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
|
|
10
11
|
* (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
|
|
11
12
|
* (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
|
|
13
|
+
*
|
|
14
|
+
* У `run:` заборонено shell-продовження рядків через `\\` перед переносом; довгі команди — через folded block `>-`.
|
|
12
15
|
*/
|
|
13
16
|
import { existsSync } from 'node:fs'
|
|
14
17
|
import { readdir, readFile } from 'node:fs/promises'
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
anyRunStepIncludes,
|
|
20
23
|
eventPathsIncludeExact,
|
|
21
24
|
findForbiddenUsesOrRunPatterns,
|
|
25
|
+
findRunStepsWithShellLineContinuationBackslash,
|
|
22
26
|
hasAnyStepUsesContaining,
|
|
23
27
|
hasCheckoutBeforeLocalSetupBunDeps,
|
|
24
28
|
parseWorkflowYaml
|
|
@@ -117,6 +121,31 @@ function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
|
|
|
117
121
|
}
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* У кроках `run` заборонено shell-продовження через `\\` перед переносом; замість `run: |` з `\\` використовуй `run: >-`.
|
|
126
|
+
* @param {string} relPath шлях для повідомлень
|
|
127
|
+
* @param {string} content вміст YAML
|
|
128
|
+
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
129
|
+
* @param {(msg: string) => void} passFn реєструє успішну перевірку
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
function verifyNoRunShellLineContinuationBackslash(relPath, content, failFn, passFn) {
|
|
133
|
+
const root = parseWorkflowYaml(content)
|
|
134
|
+
if (!root) {
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const hits = findRunStepsWithShellLineContinuationBackslash(root)
|
|
138
|
+
if (hits.length === 0) {
|
|
139
|
+
passFn(`${relPath}: run без shell-продовження через \\ (ga.mdc)`)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const h of hits) {
|
|
143
|
+
failFn(
|
|
144
|
+
`${relPath}: job ${h.jobId}, крок ${h.stepIndex + 1}: у run заборонено продовження рядків через зворотний сліш; довгі команди оформи як folded block (run: >-) без \\ на кінцях рядків (ga.mdc)`
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
120
149
|
/**
|
|
121
150
|
* Перевіряє apply-workflow на наявність paths trigger.
|
|
122
151
|
* @param {string} wfDir директорія workflows
|
|
@@ -183,6 +212,46 @@ async function checkZizmor(passFn, failFn) {
|
|
|
183
212
|
}
|
|
184
213
|
}
|
|
185
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Перевіряє `.vscode/settings.json`: oxfmt/oxc як default formatter для GitHub Actions workflow (мова
|
|
217
|
+
* `github-actions-workflow` з розширення github.vscode-github-actions), узгоджено з oxc для yaml/workflow.
|
|
218
|
+
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
219
|
+
* @param {(msg: string) => void} failFn callback при помилці
|
|
220
|
+
*/
|
|
221
|
+
async function checkVscodeSettingsForGa(passFn, failFn) {
|
|
222
|
+
const rel = '.vscode/settings.json'
|
|
223
|
+
if (!existsSync(rel)) {
|
|
224
|
+
failFn(`${rel} не існує — додай [github-actions-workflow].editor.defaultFormatter = oxc.oxc-vscode (ga.mdc)`)
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
let settings
|
|
228
|
+
try {
|
|
229
|
+
settings = JSON.parse(await readFile(rel, 'utf8'))
|
|
230
|
+
} catch {
|
|
231
|
+
failFn(`${rel}: невалідний JSON (ga.mdc)`)
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (!settings || typeof settings !== 'object') {
|
|
235
|
+
failFn(`${rel}: очікується об’єкт налаштувань (ga.mdc)`)
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
const block = /** @type {Record<string, unknown>} */ (settings)['[github-actions-workflow]']
|
|
239
|
+
if (!block || typeof block !== 'object' || block === null || Array.isArray(block)) {
|
|
240
|
+
failFn(
|
|
241
|
+
`${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
|
|
246
|
+
if (df !== 'oxc.oxc-vscode') {
|
|
247
|
+
failFn(
|
|
248
|
+
`${rel}: [github-actions-workflow].editor.defaultFormatter має бути "oxc.oxc-vscode" (зараз: ${df || '∅'}) (ga.mdc)`
|
|
249
|
+
)
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
passFn(`${rel}: [github-actions-workflow] → oxc.oxc-vscode`)
|
|
253
|
+
}
|
|
254
|
+
|
|
186
255
|
/**
|
|
187
256
|
* Перевіряє скрипт lint-ga в package.json.
|
|
188
257
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
@@ -379,6 +448,8 @@ export async function check() {
|
|
|
379
448
|
fail('.vscode/extensions.json не існує')
|
|
380
449
|
}
|
|
381
450
|
|
|
451
|
+
await checkVscodeSettingsForGa(pass, fail)
|
|
452
|
+
|
|
382
453
|
const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
|
|
383
454
|
await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
|
|
384
455
|
|
|
@@ -386,6 +457,7 @@ export async function check() {
|
|
|
386
457
|
const content = await readFile(join(wfDir, f), 'utf8')
|
|
387
458
|
verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
|
|
388
459
|
verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
|
|
460
|
+
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
389
461
|
}
|
|
390
462
|
|
|
391
463
|
await checkZizmor(pass, fail)
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -79,6 +79,17 @@
|
|
|
79
79
|
* з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
|
|
80
80
|
* наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
|
|
81
81
|
* яке буде оцінено під час збірки Kustomize).
|
|
82
|
+
*
|
|
83
|
+
* **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
|
|
84
|
+
* `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`,
|
|
85
|
+
* `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
|
|
86
|
+
* помилка `check k8s` (k8s.mdc).
|
|
87
|
+
*
|
|
88
|
+
* **HPA / PDB тільки з Deployment у `base`:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
|
|
89
|
+
* дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
|
|
90
|
+
* (рекурсивно), якщо в цьому ж дереві немає `Deployment`. У `kustomization.yaml` overlay, який підключає
|
|
91
|
+
* каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB, поки в наслідуваному `base` у дереві
|
|
92
|
+
* не з’явиться `Deployment` (k8s.mdc).
|
|
82
93
|
*/
|
|
83
94
|
import { existsSync } from 'node:fs'
|
|
84
95
|
import { readFile, readdir, stat, unlink } from 'node:fs/promises'
|
|
@@ -386,6 +397,119 @@ function pathsFromKustomizationObject(obj) {
|
|
|
386
397
|
return out
|
|
387
398
|
}
|
|
388
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
|
|
402
|
+
* як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
|
|
403
|
+
* (рядки-шляхи) і **`replacements[].path`**, якщо задано.
|
|
404
|
+
* @param {unknown} obj корінь першого документа
|
|
405
|
+
* @returns {string[]} масив локальних шляхів для перевірки існування на диску
|
|
406
|
+
*/
|
|
407
|
+
export function kustomizePathRefsForExistenceCheck(obj) {
|
|
408
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
409
|
+
return []
|
|
410
|
+
}
|
|
411
|
+
const fromPaths = pathsFromKustomizationObject(obj)
|
|
412
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
413
|
+
const pj = rec.patchesJson6902
|
|
414
|
+
if (Array.isArray(pj)) {
|
|
415
|
+
for (const item of pj) {
|
|
416
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
417
|
+
const pth = /** @type {Record<string, unknown>} */ (item).path
|
|
418
|
+
if (typeof pth === 'string' && pth.trim() !== '') {
|
|
419
|
+
fromPaths.push(pth.trim())
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const configurations = rec.configurations
|
|
425
|
+
if (Array.isArray(configurations)) {
|
|
426
|
+
for (const c of configurations) {
|
|
427
|
+
if (typeof c === 'string' && c.trim() !== '') {
|
|
428
|
+
fromPaths.push(c.trim())
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const replacements = rec.replacements
|
|
433
|
+
if (Array.isArray(replacements)) {
|
|
434
|
+
for (const r of replacements) {
|
|
435
|
+
if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
|
|
436
|
+
const pth = /** @type {Record<string, unknown>} */ (r).path
|
|
437
|
+
if (typeof pth === 'string' && pth.trim() !== '') {
|
|
438
|
+
fromPaths.push(pth.trim())
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return [...new Set(fromPaths)]
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Перевіряє, що всі перелічені в `kustomization.yaml` локальні шляхи існують.
|
|
448
|
+
* @param {string} root корінь репо
|
|
449
|
+
* @param {string} kustAbs kustomization.yaml
|
|
450
|
+
* @param {string} rootNorm нормалізований корінь
|
|
451
|
+
* @param {(msg: string) => void} fail callback
|
|
452
|
+
* @returns {Promise<void>}
|
|
453
|
+
*/
|
|
454
|
+
async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
|
|
455
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
456
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
457
|
+
if (kust === null) {
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (kust.kind !== 'Kustomization') {
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
const refs = kustomizePathRefsForExistenceCheck(kust)
|
|
464
|
+
const kustDir = dirname(resolve(kustAbs))
|
|
465
|
+
for (const r of refs) {
|
|
466
|
+
if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
|
|
467
|
+
const target = resolve(kustDir, r.trim())
|
|
468
|
+
if (resolvedFilePathIsUnderRoot(rootNorm, target)) {
|
|
469
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
470
|
+
let st
|
|
471
|
+
try {
|
|
472
|
+
st = await stat(target)
|
|
473
|
+
} catch {
|
|
474
|
+
st = undefined
|
|
475
|
+
}
|
|
476
|
+
if (st === undefined) {
|
|
477
|
+
fail(
|
|
478
|
+
`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
|
|
479
|
+
)
|
|
480
|
+
} else if (st.isFile()) {
|
|
481
|
+
if (!YAML_EXTENSION_RE.test(target)) {
|
|
482
|
+
fail(
|
|
483
|
+
`${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
} else if (!st.isDirectory()) {
|
|
487
|
+
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
fail(
|
|
491
|
+
`${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
|
|
492
|
+
}) (k8s.mdc)`
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
|
|
501
|
+
* @param {string} root корінь репозиторію
|
|
502
|
+
* @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
|
|
503
|
+
* @param {(msg: string) => void} fail callback для повідомлень про помилки
|
|
504
|
+
* @returns {Promise<void>}
|
|
505
|
+
*/
|
|
506
|
+
async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
|
|
507
|
+
const rootNorm = resolve(root)
|
|
508
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
509
|
+
await validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
389
513
|
/**
|
|
390
514
|
* Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
|
|
391
515
|
* (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
|
|
@@ -4107,6 +4231,271 @@ async function readFirstYamlObject(absPath) {
|
|
|
4107
4231
|
return null
|
|
4108
4232
|
}
|
|
4109
4233
|
|
|
4234
|
+
/**
|
|
4235
|
+
* Чи відносний шлях вказує на `k8s/…/base/kustomization.yaml` (каталог `base` у дереві k8s).
|
|
4236
|
+
* @param {string} rel POSIX-шлях
|
|
4237
|
+
* @returns {boolean} true, якщо батьківський каталог — `…/…/base` у шляху з `k8s`
|
|
4238
|
+
*/
|
|
4239
|
+
function isK8sBaseKustomizationRelPath(rel) {
|
|
4240
|
+
const n = rel.replaceAll('\\', '/')
|
|
4241
|
+
const d = dirname(n).replaceAll('\\', '/')
|
|
4242
|
+
if (basename(d) !== 'base') {
|
|
4243
|
+
return false
|
|
4244
|
+
}
|
|
4245
|
+
return d.startsWith('k8s/') || d.includes('/k8s/')
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
/**
|
|
4249
|
+
* Чи абсолютний шлях до каталогу — k8s-`base` (ідентифікуємо за тим, що `relative` від кореня
|
|
4250
|
+
* містить сегмент `k8s` і basename каталогу — `base`).
|
|
4251
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
4252
|
+
* @param {string} dirAbs абсолютний шлях до каталогу
|
|
4253
|
+
* @returns {boolean} true для `.../k8s/.../base` з `kustomization.yaml` у цьому каталозі
|
|
4254
|
+
*/
|
|
4255
|
+
function isUnderK8sPathRelToRoot(rootNorm, dirAbs) {
|
|
4256
|
+
const rel = (relative(rootNorm, dirAbs) || '.').replaceAll('\\', '/')
|
|
4257
|
+
if (rel === '' || rel === '.') {
|
|
4258
|
+
return false
|
|
4259
|
+
}
|
|
4260
|
+
if (rel.startsWith('../') || rel === '..') {
|
|
4261
|
+
return false
|
|
4262
|
+
}
|
|
4263
|
+
return rel === 'k8s' || rel.startsWith('k8s/') || rel.includes('/k8s/')
|
|
4264
|
+
}
|
|
4265
|
+
|
|
4266
|
+
/**
|
|
4267
|
+
* Чи файловий шлях усередині `dirAbs` (або збігається).
|
|
4268
|
+
* @param {string} dirAbs каталог
|
|
4269
|
+
* @param {string} fileAbs файл
|
|
4270
|
+
* @returns {boolean} true, якщо файл — піддерево каталогу
|
|
4271
|
+
*/
|
|
4272
|
+
function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
|
|
4273
|
+
const b = resolve(dirAbs)
|
|
4274
|
+
const f = resolve(fileAbs)
|
|
4275
|
+
const r = relative(b, f).replaceAll('\\', '/')
|
|
4276
|
+
if (r === '' || r === '.') {
|
|
4277
|
+
return true
|
|
4278
|
+
}
|
|
4279
|
+
return !r.startsWith('../') && r !== '..'
|
|
4280
|
+
}
|
|
4281
|
+
|
|
4282
|
+
/**
|
|
4283
|
+
* За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
|
|
4284
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
4285
|
+
* @param {string[]} pathRefs тільки resources / bases / components / crds
|
|
4286
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
4287
|
+
* @returns {Promise<string[]>} абсолютні шляхи (без дедуплікації, якщо кілька однакових ref)
|
|
4288
|
+
*/
|
|
4289
|
+
async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm) {
|
|
4290
|
+
/** @type {string[]} */
|
|
4291
|
+
const out = []
|
|
4292
|
+
for (const ref of pathRefs) {
|
|
4293
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4294
|
+
const resolved = resolve(kustDir, ref.trim())
|
|
4295
|
+
if (resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
|
|
4296
|
+
let st
|
|
4297
|
+
try {
|
|
4298
|
+
st = await stat(resolved)
|
|
4299
|
+
} catch {
|
|
4300
|
+
st = undefined
|
|
4301
|
+
}
|
|
4302
|
+
if (
|
|
4303
|
+
st !== undefined
|
|
4304
|
+
&& st.isDirectory()
|
|
4305
|
+
&& basename(resolved) === 'base'
|
|
4306
|
+
&& existsSync(join(resolved, 'kustomization.yaml'))
|
|
4307
|
+
&& isUnderK8sPathRelToRoot(rootNorm, resolved)
|
|
4308
|
+
) {
|
|
4309
|
+
out.push(resolved)
|
|
4310
|
+
}
|
|
4311
|
+
}
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
return out
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
/**
|
|
4318
|
+
* Аналізує `resources` / `bases` / `components` / `crds` kustomization: чи в дереві є
|
|
4319
|
+
* `Deployment` / HPA / PDB.
|
|
4320
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4321
|
+
* @param {string} rootNorm корінь
|
|
4322
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці
|
|
4323
|
+
*/
|
|
4324
|
+
export async function kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm) {
|
|
4325
|
+
/** @type {Set<string>} */
|
|
4326
|
+
const visitedKustomization = new Set()
|
|
4327
|
+
const desc = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization)
|
|
4328
|
+
return {
|
|
4329
|
+
hasDeployment: desc.some(d => d.kind === 'Deployment'),
|
|
4330
|
+
hasHpa: desc.some(d => d.kind === 'HorizontalPodAutoscaler'),
|
|
4331
|
+
hasPdb: desc.some(d => d.kind === 'PodDisruptionBudget')
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
|
|
4335
|
+
/**
|
|
4336
|
+
* Чи серед документів YAML-файлу є `HorizontalPodAutoscaler` або `PodDisruptionBudget`.
|
|
4337
|
+
* @param {string} fileAbs абсолютний шлях
|
|
4338
|
+
* @returns {Promise<boolean>} true, якщо такі kind знайдені
|
|
4339
|
+
*/
|
|
4340
|
+
async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
|
|
4341
|
+
const raw = await tryReadFileUtf8(fileAbs)
|
|
4342
|
+
if (raw === undefined) {
|
|
4343
|
+
return false
|
|
4344
|
+
}
|
|
4345
|
+
const docs = tryParseAllYamlDocs(raw)
|
|
4346
|
+
if (docs === undefined) {
|
|
4347
|
+
return false
|
|
4348
|
+
}
|
|
4349
|
+
return docs.some(doc => {
|
|
4350
|
+
if (doc.errors.length > 0) return false
|
|
4351
|
+
const o = doc.toJSON()
|
|
4352
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
|
|
4353
|
+
const k = /** @type {Record<string, unknown>} */ (o).kind
|
|
4354
|
+
return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
|
|
4355
|
+
})
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
/**
|
|
4359
|
+
* Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB дозволені в дереві kustomize лише разом із Deployment.
|
|
4360
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4361
|
+
* @param {string} rel для повідомлень
|
|
4362
|
+
* @param {(msg: string) => void} fail callback
|
|
4363
|
+
* @param {(msg: string) => void} passFn success
|
|
4364
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags мемоізований аналіз дерева
|
|
4365
|
+
* @returns {Promise<void>}
|
|
4366
|
+
*/
|
|
4367
|
+
async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags) {
|
|
4368
|
+
const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
|
|
4369
|
+
if (hasHpa || hasPdb) {
|
|
4370
|
+
if (hasDeployment) {
|
|
4371
|
+
passFn(
|
|
4372
|
+
`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
|
|
4373
|
+
)
|
|
4374
|
+
} else {
|
|
4375
|
+
fail(
|
|
4376
|
+
`${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
|
|
4377
|
+
)
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4382
|
+
/**
|
|
4383
|
+
* `kustomization` overlay, що посилається на `…/k8s/…/base`, не може додавати HPA / PDB як окремі YAML,
|
|
4384
|
+
* поки в наслідуваному base немає Deployment.
|
|
4385
|
+
* @param {string} root нормалізований корінь репо
|
|
4386
|
+
* @param {string} kustAbs kustomization.yaml
|
|
4387
|
+
* @param {string} rel для повідомлень
|
|
4388
|
+
* @param {Record<string, unknown>} kustObj перший документ
|
|
4389
|
+
* @param {(msg: string) => void} fail callback
|
|
4390
|
+
* @param {(msg: string) => void} passFn success
|
|
4391
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
|
|
4392
|
+
* @returns {Promise<void>}
|
|
4393
|
+
*/
|
|
4394
|
+
async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4395
|
+
root,
|
|
4396
|
+
kustAbs,
|
|
4397
|
+
rel,
|
|
4398
|
+
kustObj,
|
|
4399
|
+
fail,
|
|
4400
|
+
passFn,
|
|
4401
|
+
getTreeFlags
|
|
4402
|
+
) {
|
|
4403
|
+
const kustDir = dirname(kustAbs)
|
|
4404
|
+
const pathRefs = resourcePathRefsFromKustomizationObject(kustObj)
|
|
4405
|
+
const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, root)
|
|
4406
|
+
if (baseDirs.length === 0) {
|
|
4407
|
+
return
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
const anyBaseHasDep = await (async () => {
|
|
4411
|
+
for (const baseDir of baseDirs) {
|
|
4412
|
+
const { hasDeployment: h } = await getTreeFlags(join(baseDir, 'kustomization.yaml'))
|
|
4413
|
+
if (h) {
|
|
4414
|
+
return true
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
4417
|
+
return false
|
|
4418
|
+
})()
|
|
4419
|
+
for (const ref of pathRefs) {
|
|
4420
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4421
|
+
const fAbs = resolve(kustDir, ref.trim())
|
|
4422
|
+
if (resolvedFilePathIsUnderRoot(root, fAbs) && existsSync(fAbs)) {
|
|
4423
|
+
let st
|
|
4424
|
+
try {
|
|
4425
|
+
st = await stat(fAbs)
|
|
4426
|
+
} catch {
|
|
4427
|
+
st = undefined
|
|
4428
|
+
}
|
|
4429
|
+
if (st !== undefined && st.isFile() && YAML_EXTENSION_RE.test(fAbs)) {
|
|
4430
|
+
const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
|
|
4431
|
+
if (!fUnderSomeBase) {
|
|
4432
|
+
const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
|
|
4433
|
+
if (hpaPdb) {
|
|
4434
|
+
if (anyBaseHasDep) {
|
|
4435
|
+
passFn(
|
|
4436
|
+
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4437
|
+
)
|
|
4438
|
+
} else {
|
|
4439
|
+
fail(
|
|
4440
|
+
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4441
|
+
)
|
|
4442
|
+
}
|
|
4443
|
+
}
|
|
4444
|
+
}
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
/**
|
|
4452
|
+
* Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
|
|
4453
|
+
* посилається на base, не додає HPA/PDB без Deployment у base.
|
|
4454
|
+
* @param {string} root корінь репо
|
|
4455
|
+
* @param {string[]} yamlFilesAbs yaml у k8s
|
|
4456
|
+
* @param {(msg: string) => void} fail callback
|
|
4457
|
+
* @param {(msg: string) => void} passFn pass
|
|
4458
|
+
* @returns {Promise<void>}
|
|
4459
|
+
*/
|
|
4460
|
+
async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs, fail, passFn) {
|
|
4461
|
+
const rootNorm = resolve(root)
|
|
4462
|
+
/** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
|
|
4463
|
+
const treeFlagsMemo = new Map()
|
|
4464
|
+
/**
|
|
4465
|
+
* @param {string} kustPath абсолютний шлях до kustomization.yaml
|
|
4466
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
|
|
4467
|
+
*/
|
|
4468
|
+
const getTreeFlags = kustPath => {
|
|
4469
|
+
const k = resolve(kustPath)
|
|
4470
|
+
let p = treeFlagsMemo.get(k)
|
|
4471
|
+
if (p === undefined) {
|
|
4472
|
+
p = kustomizeResourceTreeHpaPdbDeploymentFlags(k, rootNorm)
|
|
4473
|
+
treeFlagsMemo.set(k, p)
|
|
4474
|
+
}
|
|
4475
|
+
return p
|
|
4476
|
+
}
|
|
4477
|
+
const kustFiles = yamlFilesAbs.filter(abs => basename(abs).toLowerCase() === 'kustomization.yaml')
|
|
4478
|
+
for (const kustAbs of kustFiles) {
|
|
4479
|
+
const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
4480
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
4481
|
+
if (kust !== null) {
|
|
4482
|
+
if (isK8sBaseKustomizationRelPath(rel)) {
|
|
4483
|
+
await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
|
|
4484
|
+
} else {
|
|
4485
|
+
await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4486
|
+
rootNorm,
|
|
4487
|
+
kustAbs,
|
|
4488
|
+
rel,
|
|
4489
|
+
kust,
|
|
4490
|
+
fail,
|
|
4491
|
+
passFn,
|
|
4492
|
+
getTreeFlags
|
|
4493
|
+
)
|
|
4494
|
+
}
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4110
4499
|
/**
|
|
4111
4500
|
* Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
|
|
4112
4501
|
* @param {Record<string, unknown>} kust об'єкт kustomization
|
|
@@ -4376,8 +4765,12 @@ export async function check() {
|
|
|
4376
4765
|
|
|
4377
4766
|
await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
|
|
4378
4767
|
|
|
4768
|
+
await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
|
|
4769
|
+
|
|
4379
4770
|
await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
|
|
4380
4771
|
|
|
4772
|
+
await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
|
|
4773
|
+
|
|
4381
4774
|
await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
|
|
4382
4775
|
|
|
4383
4776
|
await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Визначає, чи рядок `FROM` у Dockerfile використовує образи
|
|
3
|
+
* `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub без дзеркала
|
|
4
|
+
* `mirror.gcr.io` (GCR mirror).
|
|
5
|
+
*
|
|
6
|
+
* Правило застосовується лише до тих самих звернень, що виглядають як
|
|
7
|
+
* pull з Docker Hub (короткі імена, `docker.io/…`); приватні реєстри
|
|
8
|
+
* (hostname у першому сегменті) не оцінюються.
|
|
9
|
+
*
|
|
10
|
+
* Канонічні заміни: `mirror.gcr.io/oven/bun` та
|
|
11
|
+
* `mirror.gcr.io/library/{alpine,nginx,node}`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} t — токен образу в лапках або без
|
|
16
|
+
* @returns {string} токен без зовнішніх лапок
|
|
17
|
+
*/
|
|
18
|
+
function stripFromImageQuotes(t) {
|
|
19
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'")) {
|
|
20
|
+
return t.slice(1, -1)
|
|
21
|
+
}
|
|
22
|
+
return t
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
|
|
27
|
+
* Підтримує прапорець `--platform=…` і форму `--platform` + значення.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} line — рядок Dockerfile
|
|
30
|
+
* @returns {string | null} токен образу або null, якщо рядок не `FROM`
|
|
31
|
+
*/
|
|
32
|
+
export function getFromImageToken(line) {
|
|
33
|
+
const withoutComment = line.split('#')[0].trim()
|
|
34
|
+
if (!withoutComment) return null
|
|
35
|
+
const m = withoutComment.match(/^\s*FROM\s+(.+)$/i)
|
|
36
|
+
if (!m) return null
|
|
37
|
+
const raw = m[1].trim()
|
|
38
|
+
const tokenRe = /(?:[^\s"]+|"[^"]*")+/g
|
|
39
|
+
const tokens = raw.match(tokenRe) || []
|
|
40
|
+
let i = 0
|
|
41
|
+
while (i < tokens.length) {
|
|
42
|
+
const t = tokens[i]
|
|
43
|
+
if (t === '--platform' || t.startsWith('--platform=')) {
|
|
44
|
+
if (t.startsWith('--platform=')) {
|
|
45
|
+
i += 1
|
|
46
|
+
} else if (tokens[i + 1] === undefined) {
|
|
47
|
+
i += 1
|
|
48
|
+
} else {
|
|
49
|
+
i += 2
|
|
50
|
+
}
|
|
51
|
+
} else if (t === '--' || t.toUpperCase() === 'AS') {
|
|
52
|
+
break
|
|
53
|
+
} else if (t.startsWith('--') && t.includes('=')) {
|
|
54
|
+
i += 1
|
|
55
|
+
} else if (t.startsWith('--')) {
|
|
56
|
+
i += 1
|
|
57
|
+
} else {
|
|
58
|
+
return stripFromImageQuotes(t)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
|
|
66
|
+
* Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} imageToken — ref образу (FROM)
|
|
69
|
+
* @returns {boolean} true, якщо схоже на pull з Docker Hub
|
|
70
|
+
*/
|
|
71
|
+
export function isDockerHubStyleImageRef(imageToken) {
|
|
72
|
+
if (!imageToken) return false
|
|
73
|
+
if (/^mirror\.gcr\.io\//i.test(imageToken)) return false
|
|
74
|
+
const noDigest = imageToken.split('@')[0] || ''
|
|
75
|
+
if (!noDigest.includes('/')) {
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
const first = noDigest.split('/')[0] || ''
|
|
79
|
+
if (first === 'docker.io' || first === 'index.docker.io') return true
|
|
80
|
+
if (first.includes('.')) return false
|
|
81
|
+
if (first === 'localhost' || /^\d+\.\d+/.test(first)) return false
|
|
82
|
+
if (first.includes(':') && /^\S+:\d+$/.test(first)) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
|
|
90
|
+
*
|
|
91
|
+
* @param {string} imageToken — ref образу
|
|
92
|
+
* @returns {string} нормалізований шлях репозиторію без тега
|
|
93
|
+
*/
|
|
94
|
+
export function normalizeHubRepoPath(imageToken) {
|
|
95
|
+
let s = (imageToken.split('@')[0] || '').toLowerCase()
|
|
96
|
+
s = s.replace(/^(docker\.io|index\.docker\.io)\//, '')
|
|
97
|
+
if (!s.includes('/')) {
|
|
98
|
+
return `library/${s.split(':')[0]}`
|
|
99
|
+
}
|
|
100
|
+
const lastSl = s.lastIndexOf('/')
|
|
101
|
+
const lastCol = s.lastIndexOf(':')
|
|
102
|
+
if (lastCol > lastSl) {
|
|
103
|
+
s = s.slice(0, lastCol)
|
|
104
|
+
}
|
|
105
|
+
return s
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ ([
|
|
109
|
+
'oven/bun',
|
|
110
|
+
'library/alpine',
|
|
111
|
+
'library/nginx',
|
|
112
|
+
'library/node'
|
|
113
|
+
])
|
|
114
|
+
|
|
115
|
+
const EXPECTED_MIRROR = /** @type {const} */ ({
|
|
116
|
+
'oven/bun': 'mirror.gcr.io/oven/bun',
|
|
117
|
+
'library/alpine': 'mirror.gcr.io/library/alpine',
|
|
118
|
+
'library/nginx': 'mirror.gcr.io/library/nginx',
|
|
119
|
+
'library/node': 'mirror.gcr.io/library/node'
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Якщо образ тягнеть з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} imageToken — ref після `FROM`
|
|
126
|
+
* @returns {string | null} рекомендований `mirror.gcr.io/...` (без тега) або null
|
|
127
|
+
*/
|
|
128
|
+
export function getRequiredMirrorGcrImage(imageToken) {
|
|
129
|
+
if (!imageToken) return null
|
|
130
|
+
if (/^mirror\.gcr\.io\//i.test(imageToken)) return null
|
|
131
|
+
if (!isDockerHubStyleImageRef(imageToken)) return null
|
|
132
|
+
const norm = normalizeHubRepoPath(imageToken)
|
|
133
|
+
if (!HUB_REPOS_REQUIRING_MIRROR.includes(/** @type {any} */ (norm))) {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} fileContent — повний вміст Dockerfile
|
|
143
|
+
* @returns {string | null} повідомлення з номером рядка або null
|
|
144
|
+
*/
|
|
145
|
+
export function getMirrorGcrHint(fileContent) {
|
|
146
|
+
const lines = fileContent.split(/\r?\n/)
|
|
147
|
+
for (let n = 0; n < lines.length; n++) {
|
|
148
|
+
const line = lines[n]
|
|
149
|
+
const image = getFromImageToken(line)
|
|
150
|
+
const expected = getRequiredMirrorGcrImage(image)
|
|
151
|
+
if (expected) {
|
|
152
|
+
return `рядок ${n + 1}: FROM має тягнути ${expected} (замість ${image})`
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Використовується в check-ga, check-js-lint, check-text, check-style-lint, check-npm-module замість
|
|
5
5
|
* пошуку підрядків у сирому тексті там, де важливі лише значення `uses:` та `run:` кроків.
|
|
6
|
+
*
|
|
7
|
+
* Для `run:` також виявляється shell-продовження рядка через `\\` перед переносом (антипатерн у ga.mdc).
|
|
6
8
|
*/
|
|
7
9
|
import { parse } from 'yaml'
|
|
8
10
|
|
|
@@ -67,6 +69,36 @@ export function getStepRun(step) {
|
|
|
67
69
|
return ''
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** У тексті `run:` зіставляє `\\` одразу перед переносом рядка (типове shell-продовження в bash). */
|
|
73
|
+
const RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE = /\\\r?\n/
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Чи містить значення `run:` shell-продовження рядка через зворотний сліш перед переносом (`… \\` + NL).
|
|
77
|
+
* У workflow такі конструкції замінюють на folded block `>-` без зворотних слішів (ga.mdc).
|
|
78
|
+
* @param {string} runText текст з `getStepRun`
|
|
79
|
+
* @returns {boolean} `true`, якщо знайдено `\\` перед новим рядком
|
|
80
|
+
*/
|
|
81
|
+
export function runTextHasShellLineContinuationBackslash(runText) {
|
|
82
|
+
return typeof runText === 'string' && runText.length > 0 && RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE.test(runText)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Повертає кроки, у яких `run:` містить заборонене shell-продовження через `\\`.
|
|
87
|
+
* @param {Record<string, unknown>} root корінь workflow
|
|
88
|
+
* @returns {{ jobId: string, stepIndex: number }[]} список кроків із порушенням
|
|
89
|
+
*/
|
|
90
|
+
export function findRunStepsWithShellLineContinuationBackslash(root) {
|
|
91
|
+
/** @type {{ jobId: string, stepIndex: number }[]} */
|
|
92
|
+
const out = []
|
|
93
|
+
for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
|
|
94
|
+
const run = getStepRun(step)
|
|
95
|
+
if (runTextHasShellLineContinuationBackslash(run)) {
|
|
96
|
+
out.push({ jobId, stepIndex })
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
/**
|
|
71
103
|
* Чи є крок, у якого `uses` містить будь-який з підрядків.
|
|
72
104
|
* @param {Record<string, unknown>} root корінь workflow
|