@nitra/cursor 1.8.125 → 1.8.128
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 +4 -2
- package/package.json +1 -1
- package/scripts/check-abie.mjs +67 -77
- package/scripts/check-docker.mjs +77 -0
- package/scripts/check-ga.mjs +70 -0
- package/scripts/check-k8s.mjs +228 -164
- package/scripts/utils/docker-mirror.mjs +154 -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
|
|
@@ -298,6 +298,8 @@ data:
|
|
|
298
298
|
|
|
299
299
|
**Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
|
|
300
300
|
|
|
301
|
+
**Порядок `resources`:** у маніфесті з **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**, елементи **`resources:`** (лише непорожні рядки) мають бути **відсортовані за алфавітом (англ. локаль, як `localeCompare('en')` у `check-k8s.mjs`)**. Поля **`bases`**, **`components`**, **`crds`** цією перевіркою **не** впорядковуються.
|
|
302
|
+
|
|
301
303
|
### Env-залежні межі (за сегментом після `/k8s/`)
|
|
302
304
|
|
|
303
305
|
**Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
|
|
@@ -490,7 +492,7 @@ patches:
|
|
|
490
492
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
|
|
491
493
|
```
|
|
492
494
|
|
|
493
|
-
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/
|
|
495
|
+
**Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
|
|
494
496
|
|
|
495
497
|
```yaml
|
|
496
498
|
# yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
|
package/package.json
CHANGED
package/scripts/check-abie.mjs
CHANGED
|
@@ -225,6 +225,20 @@ export function isAllowedAbieBaseDevHostname(hostname) {
|
|
|
225
225
|
return false
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* @param {unknown} hostnames значення поля spec.hostnames
|
|
230
|
+
* @returns {string[]} непорожні рядки-хости
|
|
231
|
+
*/
|
|
232
|
+
function collectAbieHostnames(hostnames) {
|
|
233
|
+
if (Array.isArray(hostnames)) {
|
|
234
|
+
return hostnames.filter(h => typeof h === 'string' && h.trim() !== '')
|
|
235
|
+
}
|
|
236
|
+
if (typeof hostnames === 'string' && hostnames.trim() !== '') {
|
|
237
|
+
return [hostnames]
|
|
238
|
+
}
|
|
239
|
+
return []
|
|
240
|
+
}
|
|
241
|
+
|
|
228
242
|
/**
|
|
229
243
|
* Повідомлення про недопустимі **spec.hostnames** у **HTTPRoute** у шляху **…/base/…** (abie.mdc).
|
|
230
244
|
* @param {unknown} obj корінь YAML-документа
|
|
@@ -232,49 +246,23 @@ export function isAllowedAbieBaseDevHostname(hostname) {
|
|
|
232
246
|
* @returns {string[]} порожньо, якщо перевірка не застосовується або hostnames коректні
|
|
233
247
|
*/
|
|
234
248
|
export function abieBaseHttpRouteHostnamesErrors(obj, rel) {
|
|
235
|
-
if (!isAbieK8sBaseYamlPath(rel))
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
239
|
-
return []
|
|
240
|
-
}
|
|
249
|
+
if (!isAbieK8sBaseYamlPath(rel)) return []
|
|
250
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
|
|
241
251
|
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
242
|
-
if (rec.kind !== 'HTTPRoute')
|
|
243
|
-
return []
|
|
244
|
-
}
|
|
252
|
+
if (rec.kind !== 'HTTPRoute') return []
|
|
245
253
|
const spec = rec.spec
|
|
246
|
-
if (spec === null || typeof spec !== 'object' || Array.isArray(spec))
|
|
247
|
-
return []
|
|
248
|
-
}
|
|
254
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return []
|
|
249
255
|
const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
|
|
250
|
-
if (hostnames === undefined)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
/** @type {string[]} */
|
|
254
|
-
const hosts = []
|
|
255
|
-
if (Array.isArray(hostnames)) {
|
|
256
|
-
for (const h of hostnames) {
|
|
257
|
-
if (typeof h === 'string' && h.trim() !== '') {
|
|
258
|
-
hosts.push(h)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
} else if (typeof hostnames === 'string' && hostnames.trim() !== '') {
|
|
262
|
-
hosts.push(hostnames)
|
|
263
|
-
}
|
|
264
|
-
if (hosts.length === 0) {
|
|
265
|
-
return []
|
|
266
|
-
}
|
|
256
|
+
if (hostnames === undefined) return []
|
|
257
|
+
const hosts = collectAbieHostnames(hostnames)
|
|
258
|
+
if (hosts.length === 0) return []
|
|
267
259
|
const root = ABIE_BASE_DEV_HTTPROUTE_HOST_ROOT
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
errors.push(
|
|
260
|
+
return hosts
|
|
261
|
+
.filter(h => !isAllowedAbieBaseDevHostname(h))
|
|
262
|
+
.map(
|
|
263
|
+
h =>
|
|
273
264
|
`${rel}: HTTPRoute у base (dev): hostname "${h}" недопустимий — дозволені лише ${root} та піддомени, зокрема *.${root} (abie.mdc)`
|
|
274
|
-
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
return errors
|
|
265
|
+
)
|
|
278
266
|
}
|
|
279
267
|
|
|
280
268
|
/**
|
|
@@ -1591,6 +1579,37 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
|
|
|
1591
1579
|
return true
|
|
1592
1580
|
}
|
|
1593
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* @param {unknown} json YAML-документ
|
|
1584
|
+
* @returns {boolean} true, якщо HTTPRoute має непорожні spec.hostnames
|
|
1585
|
+
*/
|
|
1586
|
+
function httpRouteHasNonEmptyHostnames(json) {
|
|
1587
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) return false
|
|
1588
|
+
const rec = /** @type {Record<string, unknown>} */ (json)
|
|
1589
|
+
if (rec.kind !== 'HTTPRoute') return false
|
|
1590
|
+
const spec = rec.spec
|
|
1591
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
|
|
1592
|
+
const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
|
|
1593
|
+
return collectAbieHostnames(hostnames).length > 0
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* @param {import('yaml').Document} doc YAML-документ з файлу
|
|
1598
|
+
* @param {string} rel відносний шлях для повідомлень
|
|
1599
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
1600
|
+
* @returns {{ hasErrors: boolean, hasHostnames: boolean }} результат обробки документа
|
|
1601
|
+
*/
|
|
1602
|
+
function processBaseHttpRouteDoc(doc, rel, fail) {
|
|
1603
|
+
if (doc.errors.length !== 0) return { hasErrors: false, hasHostnames: false }
|
|
1604
|
+
const json = doc.toJSON()
|
|
1605
|
+
const errs = abieBaseHttpRouteHostnamesErrors(json, rel)
|
|
1606
|
+
if (errs.length > 0) {
|
|
1607
|
+
for (const e of errs) fail(e)
|
|
1608
|
+
return { hasErrors: true, hasHostnames: false }
|
|
1609
|
+
}
|
|
1610
|
+
return { hasErrors: false, hasHostnames: httpRouteHasNonEmptyHostnames(json) }
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1594
1613
|
/**
|
|
1595
1614
|
* Для кожного **HTTPRoute** у **`…/k8s/base/…`** з непорожніми **`spec.hostnames`** — лише **aiml.live** та піддомени (abie.mdc).
|
|
1596
1615
|
* @param {string} root корінь репозиторію
|
|
@@ -1601,39 +1620,15 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
|
|
|
1601
1620
|
*/
|
|
1602
1621
|
async function ensureAbieBaseHttpRouteHostnames(root, yamlFilesAbs, fail, passFn) {
|
|
1603
1622
|
let baseHttpRoutesWithHostnames = 0
|
|
1604
|
-
|
|
1623
|
+
const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
|
|
1624
|
+
for (const abs of baseFiles) {
|
|
1605
1625
|
const rel = relative(root, abs).replaceAll('\\', '/') || abs
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
if (doc.errors.length === 0) {
|
|
1613
|
-
const json = doc.toJSON()
|
|
1614
|
-
const errs = abieBaseHttpRouteHostnamesErrors(json, rel)
|
|
1615
|
-
if (errs.length > 0) {
|
|
1616
|
-
for (const e of errs) {
|
|
1617
|
-
fail(e)
|
|
1618
|
-
}
|
|
1619
|
-
return
|
|
1620
|
-
}
|
|
1621
|
-
if (json !== null && typeof json === 'object' && !Array.isArray(json)) {
|
|
1622
|
-
const rec = /** @type {Record<string, unknown>} */ (json)
|
|
1623
|
-
if (rec.kind === 'HTTPRoute') {
|
|
1624
|
-
const spec = rec.spec
|
|
1625
|
-
if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
|
|
1626
|
-
const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
|
|
1627
|
-
if (Array.isArray(hostnames) && hostnames.some(h => typeof h === 'string' && h.trim() !== '')) {
|
|
1628
|
-
baseHttpRoutesWithHostnames++
|
|
1629
|
-
} else if (typeof hostnames === 'string' && hostnames.trim() !== '') {
|
|
1630
|
-
baseHttpRoutesWithHostnames++
|
|
1631
|
-
}
|
|
1632
|
-
}
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
}
|
|
1626
|
+
const docs = await readAndParseYamlDocs(abs, rel, fail)
|
|
1627
|
+
if (!docs) return
|
|
1628
|
+
for (const doc of docs) {
|
|
1629
|
+
const { hasErrors, hasHostnames } = processBaseHttpRouteDoc(doc, rel, fail)
|
|
1630
|
+
if (hasErrors) return
|
|
1631
|
+
if (hasHostnames) baseHttpRoutesWithHostnames++
|
|
1637
1632
|
}
|
|
1638
1633
|
}
|
|
1639
1634
|
if (baseHttpRoutesWithHostnames > 0) {
|
|
@@ -1685,7 +1680,6 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
|
|
|
1685
1680
|
/**
|
|
1686
1681
|
* Перевіряє відсутність артефактів Firebase Hosting у **кожному** **підкаталозі першого рівня** від кореня
|
|
1687
1682
|
* (не в самому корені репозиторію) — abie.mdc. Каталоги **`.git`** і **`node_modules`** у скануванні пропускаються.
|
|
1688
|
-
*
|
|
1689
1683
|
* @param {string} root корінь репозиторію
|
|
1690
1684
|
* @param {(msg: string) => void} passFn успішне повідомлення
|
|
1691
1685
|
* @param {(msg: string) => void} failFn повідомлення про порушення
|
|
@@ -1700,9 +1694,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
|
|
|
1700
1694
|
failFn(`Не вдалося прочитати ${root} для перевірки Firebase Hosting: ${msg} (abie.mdc)`)
|
|
1701
1695
|
return
|
|
1702
1696
|
}
|
|
1703
|
-
const topDirs = entries.filter(
|
|
1704
|
-
e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name)
|
|
1705
|
-
)
|
|
1697
|
+
const topDirs = entries.filter(e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name))
|
|
1706
1698
|
let hasViolation = false
|
|
1707
1699
|
for (const e of topDirs) {
|
|
1708
1700
|
for (const name of ['.firebaserc', 'firebase.json']) {
|
|
@@ -1720,9 +1712,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
|
|
|
1720
1712
|
if (hasViolation) {
|
|
1721
1713
|
return
|
|
1722
1714
|
}
|
|
1723
|
-
passFn(
|
|
1724
|
-
'Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)'
|
|
1725
|
-
)
|
|
1715
|
+
passFn('Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)')
|
|
1726
1716
|
}
|
|
1727
1717
|
|
|
1728
1718
|
/**
|
package/scripts/check-docker.mjs
CHANGED
|
@@ -1,16 +1,38 @@
|
|
|
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
|
+
const NEWLINE_RE = /\r?\n/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{
|
|
31
|
+
* line: number
|
|
32
|
+
* image: string
|
|
33
|
+
* }} FromStage
|
|
34
|
+
*/
|
|
35
|
+
|
|
14
36
|
/**
|
|
15
37
|
* Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
|
|
16
38
|
* @param {string} name basename шляху
|
|
@@ -37,6 +59,50 @@ export async function findDockerfilePaths(root) {
|
|
|
37
59
|
return out.toSorted((a, b) => a.localeCompare(b))
|
|
38
60
|
}
|
|
39
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
|
|
64
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
65
|
+
* @returns {FromStage[]} список знайдених FROM-інструкцій
|
|
66
|
+
*/
|
|
67
|
+
export function parseFromStages(fileContent) {
|
|
68
|
+
const out = []
|
|
69
|
+
const lines = fileContent.split(NEWLINE_RE)
|
|
70
|
+
for (const [i, line] of lines.entries()) {
|
|
71
|
+
const image = getFromImageToken(line)
|
|
72
|
+
if (image) out.push({ line: i + 1, image })
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const RUNTIME_IMAGES = /** @type {const} */ (['mirror.gcr.io/library/alpine', 'mirror.gcr.io/library/nginx'])
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Перевіряє базові вимоги до структури Dockerfile:
|
|
81
|
+
* - multistage: мінімум 2 FROM
|
|
82
|
+
* - фінальний FROM: alpine або nginx з mirror.gcr.io
|
|
83
|
+
* @param {string} fileContent вміст Dockerfile/Containerfile
|
|
84
|
+
* @returns {string | null} повідомлення помилки або null
|
|
85
|
+
*/
|
|
86
|
+
export function getMultistageAndRuntimeHint(fileContent) {
|
|
87
|
+
const stages = parseFromStages(fileContent)
|
|
88
|
+
if (stages.length === 0) return null
|
|
89
|
+
|
|
90
|
+
if (stages.length < 2) {
|
|
91
|
+
return 'має бути multistage build: мінімум 2 інструкції FROM (build stage + runtime stage)'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const last = stages.at(-1)
|
|
95
|
+
const lastImage = (last?.image || '').split('@')[0] || ''
|
|
96
|
+
const lastLower = lastImage.toLowerCase()
|
|
97
|
+
|
|
98
|
+
const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
|
|
99
|
+
if (!okRuntime) {
|
|
100
|
+
return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
40
106
|
/**
|
|
41
107
|
* Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
|
|
42
108
|
* @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
|
|
@@ -57,6 +123,17 @@ export async function check() {
|
|
|
57
123
|
|
|
58
124
|
for (const abs of files) {
|
|
59
125
|
const rel = posixRel(root, abs) || basename(abs)
|
|
126
|
+
const content = await readFile(abs, 'utf8')
|
|
127
|
+
const hint = getMirrorGcrHint(content)
|
|
128
|
+
if (hint) {
|
|
129
|
+
fail(`${rel} (mirror.gcr.io): ${hint}`)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const multistageHint = getMultistageAndRuntimeHint(content)
|
|
133
|
+
if (multistageHint) {
|
|
134
|
+
fail(`${rel} (multistage): ${multistageHint}`)
|
|
135
|
+
}
|
|
136
|
+
|
|
60
137
|
const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
|
|
61
138
|
const tail = (stdout + stderr).trim()
|
|
62
139
|
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(String.raw`${relPath}: run без shell-продовження через \ (ga.mdc)`)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
for (const h of hits) {
|
|
143
|
+
failFn(
|
|
144
|
+
String.raw`${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,44 @@ 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(`${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
|
|
244
|
+
if (df !== 'oxc.oxc-vscode') {
|
|
245
|
+
failFn(
|
|
246
|
+
`${rel}: [github-actions-workflow].editor.defaultFormatter має бути "oxc.oxc-vscode" (зараз: ${df || '∅'}) (ga.mdc)`
|
|
247
|
+
)
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
passFn(`${rel}: [github-actions-workflow] → oxc.oxc-vscode`)
|
|
251
|
+
}
|
|
252
|
+
|
|
186
253
|
/**
|
|
187
254
|
* Перевіряє скрипт lint-ga в package.json.
|
|
188
255
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
@@ -379,6 +446,8 @@ export async function check() {
|
|
|
379
446
|
fail('.vscode/extensions.json не існує')
|
|
380
447
|
}
|
|
381
448
|
|
|
449
|
+
await checkVscodeSettingsForGa(pass, fail)
|
|
450
|
+
|
|
382
451
|
const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
|
|
383
452
|
await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
|
|
384
453
|
|
|
@@ -386,6 +455,7 @@ export async function check() {
|
|
|
386
455
|
const content = await readFile(join(wfDir, f), 'utf8')
|
|
387
456
|
verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
|
|
388
457
|
verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
|
|
458
|
+
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
389
459
|
}
|
|
390
460
|
|
|
391
461
|
await checkZizmor(pass, fail)
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
* в **тому ж каталозі**, що й **`svc.yaml`** (логіка збігається з **`pathsFromKustomizationObject`**).
|
|
43
43
|
*
|
|
44
44
|
* Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
|
|
45
|
-
* завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
|
|
45
|
+
* завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує). У **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**
|
|
46
|
+
* перелік **`resources:`** (лише непорожні рядки) має бути відсортовано за алфавітом (**en**, `localeCompare`).
|
|
46
47
|
*
|
|
47
48
|
* **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
|
|
48
49
|
* на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
|
|
@@ -365,6 +366,77 @@ function pushStringPaths(arr, acc) {
|
|
|
365
366
|
}
|
|
366
367
|
}
|
|
367
368
|
|
|
369
|
+
/** Префікс `apiVersion` для маніфесту Kustomize **Kustomization**. */
|
|
370
|
+
const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Чи послідовність непорожніх рядків зростаюча за `localeCompare` (en).
|
|
374
|
+
* @param {string[]} paths
|
|
375
|
+
* @returns {boolean}
|
|
376
|
+
*/
|
|
377
|
+
function stringPathsAreSortedEn(paths) {
|
|
378
|
+
for (let i = 1; i < paths.length; i++) {
|
|
379
|
+
if (paths[i - 1].localeCompare(paths[i], 'en', { sensitivity: 'base' }) > 0) {
|
|
380
|
+
return false
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return true
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Порушення сорту **`resources`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
|
|
388
|
+
* Порожні рядки в списку ігноруються (як у `pushStringPaths`).
|
|
389
|
+
* @param {unknown} obj корінь першого YAML-документа
|
|
390
|
+
* @returns {string | null} причина або `null`, якщо обмеження не застосовується
|
|
391
|
+
*/
|
|
392
|
+
export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
|
|
393
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
|
|
394
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
395
|
+
if (rec.kind !== 'Kustomization') return null
|
|
396
|
+
const av = rec.apiVersion
|
|
397
|
+
if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
|
|
398
|
+
const res = rec.resources
|
|
399
|
+
if (res === undefined) return null
|
|
400
|
+
if (!Array.isArray(res)) {
|
|
401
|
+
return 'Kustomization.resources має бути масивом (k8s.mdc)'
|
|
402
|
+
}
|
|
403
|
+
/** @type {string[]} */
|
|
404
|
+
const paths = []
|
|
405
|
+
for (let i = 0; i < res.length; i++) {
|
|
406
|
+
const item = res[i]
|
|
407
|
+
if (typeof item !== 'string') {
|
|
408
|
+
return `Kustomization.resources[${i}] — очікується рядок-шлях (k8s.mdc)`
|
|
409
|
+
}
|
|
410
|
+
const t = item.trim()
|
|
411
|
+
if (t !== '') paths.push(t)
|
|
412
|
+
}
|
|
413
|
+
if (paths.length < 2) return null
|
|
414
|
+
if (!stringPathsAreSortedEn(paths)) {
|
|
415
|
+
const want = [...paths].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
|
|
416
|
+
return `Kustomization.resources має бути за алфавітом (en). Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
|
|
417
|
+
}
|
|
418
|
+
return null
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Усі **`kustomization.yaml`**: **`resources`**, відсортовані за en.
|
|
423
|
+
* @param {string} root корінь репо
|
|
424
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
425
|
+
* @param {(msg: string) => void} fail
|
|
426
|
+
* @returns {Promise<void>}
|
|
427
|
+
*/
|
|
428
|
+
async function validateKustomizationResourcesSortedAlphabetically(root, yamlFilesAbs, fail) {
|
|
429
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
430
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
431
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
432
|
+
if (kust === null) continue
|
|
433
|
+
const v = kustomizationResourcesSortedAlphabeticallyViolation(kust)
|
|
434
|
+
if (v !== null) {
|
|
435
|
+
fail(`${rel}: ${v}`)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
368
440
|
/**
|
|
369
441
|
* Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
|
|
370
442
|
* @param {unknown} obj корінь першого документа Kustomization
|
|
@@ -397,12 +469,41 @@ function pathsFromKustomizationObject(obj) {
|
|
|
397
469
|
return out
|
|
398
470
|
}
|
|
399
471
|
|
|
472
|
+
/**
|
|
473
|
+
* @param {unknown} arr масив (може бути не масивом)
|
|
474
|
+
* @param {string[]} out вихідний масив
|
|
475
|
+
*/
|
|
476
|
+
function collectObjectPathFields(arr, out) {
|
|
477
|
+
if (!Array.isArray(arr)) return
|
|
478
|
+
for (const item of arr) {
|
|
479
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
|
|
480
|
+
const pth = /** @type {Record<string, unknown>} */ (item).path
|
|
481
|
+
if (typeof pth === 'string' && pth.trim() !== '') {
|
|
482
|
+
out.push(pth.trim())
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* @param {unknown} arr масив (може бути не масивом)
|
|
490
|
+
* @param {string[]} out вихідний масив
|
|
491
|
+
*/
|
|
492
|
+
function collectStringPaths(arr, out) {
|
|
493
|
+
if (!Array.isArray(arr)) return
|
|
494
|
+
for (const c of arr) {
|
|
495
|
+
if (typeof c === 'string' && c.trim() !== '') {
|
|
496
|
+
out.push(c.trim())
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
400
501
|
/**
|
|
401
502
|
* Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
|
|
402
503
|
* як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
|
|
403
504
|
* (рядки-шляхи) і **`replacements[].path`**, якщо задано.
|
|
404
505
|
* @param {unknown} obj корінь першого документа
|
|
405
|
-
* @returns {string[]}
|
|
506
|
+
* @returns {string[]} масив локальних шляхів для перевірки існування на диску
|
|
406
507
|
*/
|
|
407
508
|
export function kustomizePathRefsForExistenceCheck(obj) {
|
|
408
509
|
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
@@ -410,37 +511,48 @@ export function kustomizePathRefsForExistenceCheck(obj) {
|
|
|
410
511
|
}
|
|
411
512
|
const fromPaths = pathsFromKustomizationObject(obj)
|
|
412
513
|
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
514
|
+
collectObjectPathFields(rec.patchesJson6902, fromPaths)
|
|
515
|
+
collectStringPaths(rec.configurations, fromPaths)
|
|
516
|
+
collectObjectPathFields(rec.replacements, fromPaths)
|
|
517
|
+
return [...new Set(fromPaths)]
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @param {string} rel відносний шлях файлу
|
|
522
|
+
* @param {string} r посилання з kustomization
|
|
523
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
524
|
+
* @param {string} rootNorm нормалізований корінь
|
|
525
|
+
* @param {(msg: string) => void} fail callback
|
|
526
|
+
* @returns {Promise<void>}
|
|
527
|
+
*/
|
|
528
|
+
async function validateKustomizationRef(rel, r, kustDir, rootNorm, fail) {
|
|
529
|
+
const target = resolve(kustDir, r.trim())
|
|
530
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
|
|
531
|
+
fail(
|
|
532
|
+
`${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(
|
|
533
|
+
relative(rootNorm, target) || target
|
|
534
|
+
).replaceAll('\\', '/')}) (k8s.mdc)`
|
|
535
|
+
)
|
|
536
|
+
return
|
|
423
537
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
}
|
|
538
|
+
/** @type {import('node:fs').Stats | undefined} */
|
|
539
|
+
let st
|
|
540
|
+
try {
|
|
541
|
+
st = await stat(target)
|
|
542
|
+
} catch {
|
|
543
|
+
st = undefined
|
|
431
544
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
}
|
|
545
|
+
if (st === undefined) {
|
|
546
|
+
fail(`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`)
|
|
547
|
+
} else if (st.isFile()) {
|
|
548
|
+
if (!YAML_EXTENSION_RE.test(target)) {
|
|
549
|
+
fail(
|
|
550
|
+
`${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
|
|
551
|
+
)
|
|
441
552
|
}
|
|
553
|
+
} else if (!st.isDirectory()) {
|
|
554
|
+
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
442
555
|
}
|
|
443
|
-
return [...new Set(fromPaths)]
|
|
444
556
|
}
|
|
445
557
|
|
|
446
558
|
/**
|
|
@@ -454,56 +566,23 @@ export function kustomizePathRefsForExistenceCheck(obj) {
|
|
|
454
566
|
async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
|
|
455
567
|
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
456
568
|
const kust = await readFirstYamlObject(kustAbs)
|
|
457
|
-
if (kust === null) {
|
|
458
|
-
return
|
|
459
|
-
}
|
|
460
|
-
if (kust.kind !== 'Kustomization') {
|
|
569
|
+
if (kust === null || kust.kind !== 'Kustomization') {
|
|
461
570
|
return
|
|
462
571
|
}
|
|
463
572
|
const refs = kustomizePathRefsForExistenceCheck(kust)
|
|
464
573
|
const kustDir = dirname(resolve(kustAbs))
|
|
465
574
|
for (const r of refs) {
|
|
466
|
-
if (typeof r
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
const target = resolve(kustDir, r.trim())
|
|
470
|
-
if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
|
|
471
|
-
fail(
|
|
472
|
-
`${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
|
|
473
|
-
}) (k8s.mdc)`
|
|
474
|
-
)
|
|
475
|
-
continue
|
|
476
|
-
}
|
|
477
|
-
/** @type {import('node:fs').Stats | undefined} */
|
|
478
|
-
let st
|
|
479
|
-
try {
|
|
480
|
-
st = await stat(target)
|
|
481
|
-
} catch {
|
|
482
|
-
st = undefined
|
|
483
|
-
}
|
|
484
|
-
if (st === undefined) {
|
|
485
|
-
fail(
|
|
486
|
-
`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
|
|
487
|
-
)
|
|
488
|
-
continue
|
|
489
|
-
}
|
|
490
|
-
if (st.isFile()) {
|
|
491
|
-
if (!YAML_EXTENSION_RE.test(target)) {
|
|
492
|
-
fail(
|
|
493
|
-
`${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
|
|
494
|
-
)
|
|
495
|
-
}
|
|
496
|
-
} else if (!st.isDirectory()) {
|
|
497
|
-
fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
|
|
575
|
+
if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
|
|
576
|
+
await validateKustomizationRef(rel, r, kustDir, rootNorm, fail)
|
|
498
577
|
}
|
|
499
578
|
}
|
|
500
579
|
}
|
|
501
580
|
|
|
502
581
|
/**
|
|
503
582
|
* Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
|
|
504
|
-
* @param {string} root
|
|
505
|
-
* @param {string[]} yamlFilesAbs
|
|
506
|
-
* @param {(msg: string) => void} fail
|
|
583
|
+
* @param {string} root корінь репозиторію
|
|
584
|
+
* @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
|
|
585
|
+
* @param {(msg: string) => void} fail callback для повідомлень про помилки
|
|
507
586
|
* @returns {Promise<void>}
|
|
508
587
|
*/
|
|
509
588
|
async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
|
|
@@ -4282,6 +4361,24 @@ function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
|
|
|
4282
4361
|
return !r.startsWith('../') && r !== '..'
|
|
4283
4362
|
}
|
|
4284
4363
|
|
|
4364
|
+
/**
|
|
4365
|
+
* @param {string} resolved абсолютний шлях
|
|
4366
|
+
* @param {string} rootNorm нормалізований корінь
|
|
4367
|
+
* @returns {Promise<boolean>} true, якщо resolved є k8s base-каталогом з kustomization.yaml
|
|
4368
|
+
*/
|
|
4369
|
+
async function isK8sBaseDir(resolved, rootNorm) {
|
|
4370
|
+
if (basename(resolved) !== 'base') return false
|
|
4371
|
+
if (!existsSync(join(resolved, 'kustomization.yaml'))) return false
|
|
4372
|
+
if (!isUnderK8sPathRelToRoot(rootNorm, resolved)) return false
|
|
4373
|
+
let st
|
|
4374
|
+
try {
|
|
4375
|
+
st = await stat(resolved)
|
|
4376
|
+
} catch {
|
|
4377
|
+
return false
|
|
4378
|
+
}
|
|
4379
|
+
return st.isDirectory()
|
|
4380
|
+
}
|
|
4381
|
+
|
|
4285
4382
|
/**
|
|
4286
4383
|
* За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
|
|
4287
4384
|
* @param {string} kustDir каталог kustomization.yaml
|
|
@@ -4293,26 +4390,12 @@ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootN
|
|
|
4293
4390
|
/** @type {string[]} */
|
|
4294
4391
|
const out = []
|
|
4295
4392
|
for (const ref of pathRefs) {
|
|
4296
|
-
if (typeof ref
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
|
|
4300
|
-
|
|
4301
|
-
continue
|
|
4302
|
-
}
|
|
4303
|
-
let st
|
|
4304
|
-
try {
|
|
4305
|
-
st = await stat(resolved)
|
|
4306
|
-
} catch {
|
|
4307
|
-
st = undefined
|
|
4308
|
-
}
|
|
4309
|
-
if (st === undefined || !st.isDirectory() || basename(resolved) !== 'base') {
|
|
4310
|
-
continue
|
|
4311
|
-
}
|
|
4312
|
-
if (!existsSync(join(resolved, 'kustomization.yaml')) || !isUnderK8sPathRelToRoot(rootNorm, resolved)) {
|
|
4313
|
-
continue
|
|
4393
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4394
|
+
const resolved = resolve(kustDir, ref.trim())
|
|
4395
|
+
if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && (await isK8sBaseDir(resolved, rootNorm))) {
|
|
4396
|
+
out.push(resolved)
|
|
4397
|
+
}
|
|
4314
4398
|
}
|
|
4315
|
-
out.push(resolved)
|
|
4316
4399
|
}
|
|
4317
4400
|
return out
|
|
4318
4401
|
}
|
|
@@ -4349,20 +4432,13 @@ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
|
|
|
4349
4432
|
if (docs === undefined) {
|
|
4350
4433
|
return false
|
|
4351
4434
|
}
|
|
4352
|
-
|
|
4353
|
-
if (doc.errors.length > 0)
|
|
4354
|
-
continue
|
|
4355
|
-
}
|
|
4435
|
+
return docs.some(doc => {
|
|
4436
|
+
if (doc.errors.length > 0) return false
|
|
4356
4437
|
const o = doc.toJSON()
|
|
4357
|
-
if (o === null || typeof o !== 'object' || Array.isArray(o))
|
|
4358
|
-
continue
|
|
4359
|
-
}
|
|
4438
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
|
|
4360
4439
|
const k = /** @type {Record<string, unknown>} */ (o).kind
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
}
|
|
4364
|
-
}
|
|
4365
|
-
return false
|
|
4440
|
+
return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
|
|
4441
|
+
})
|
|
4366
4442
|
}
|
|
4367
4443
|
|
|
4368
4444
|
/**
|
|
@@ -4378,9 +4454,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
|
|
|
4378
4454
|
const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
|
|
4379
4455
|
if (hasHpa || hasPdb) {
|
|
4380
4456
|
if (hasDeployment) {
|
|
4381
|
-
passFn(
|
|
4382
|
-
`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
|
|
4383
|
-
)
|
|
4457
|
+
passFn(`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`)
|
|
4384
4458
|
} else {
|
|
4385
4459
|
fail(
|
|
4386
4460
|
`${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
|
|
@@ -4398,7 +4472,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
|
|
|
4398
4472
|
* @param {Record<string, unknown>} kustObj перший документ
|
|
4399
4473
|
* @param {(msg: string) => void} fail callback
|
|
4400
4474
|
* @param {(msg: string) => void} passFn success
|
|
4401
|
-
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
|
|
4475
|
+
* @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
|
|
4402
4476
|
* @returns {Promise<void>}
|
|
4403
4477
|
*/
|
|
4404
4478
|
async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
@@ -4417,55 +4491,52 @@ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
|
4417
4491
|
return
|
|
4418
4492
|
}
|
|
4419
4493
|
|
|
4420
|
-
const
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
if (h) {
|
|
4424
|
-
return true
|
|
4425
|
-
}
|
|
4426
|
-
}
|
|
4427
|
-
return false
|
|
4428
|
-
})()
|
|
4494
|
+
const treeFlags = await Promise.all(baseDirs.map(bd => getTreeFlags(join(bd, 'kustomization.yaml'))))
|
|
4495
|
+
const anyBaseHasDep = treeFlags.some(f => f.hasDeployment)
|
|
4496
|
+
|
|
4429
4497
|
for (const ref of pathRefs) {
|
|
4430
|
-
if (typeof ref
|
|
4431
|
-
|
|
4432
|
-
}
|
|
4433
|
-
const fAbs = resolve(kustDir, ref.trim())
|
|
4434
|
-
if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) {
|
|
4435
|
-
continue
|
|
4436
|
-
}
|
|
4437
|
-
let st
|
|
4438
|
-
try {
|
|
4439
|
-
st = await stat(fAbs)
|
|
4440
|
-
} catch {
|
|
4441
|
-
st = undefined
|
|
4442
|
-
}
|
|
4443
|
-
if (st === undefined) {
|
|
4444
|
-
continue
|
|
4445
|
-
}
|
|
4446
|
-
if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) {
|
|
4447
|
-
continue
|
|
4448
|
-
}
|
|
4449
|
-
const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
|
|
4450
|
-
if (fUnderSomeBase) {
|
|
4451
|
-
continue
|
|
4452
|
-
}
|
|
4453
|
-
const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
|
|
4454
|
-
if (!hpaPdb) {
|
|
4455
|
-
continue
|
|
4456
|
-
}
|
|
4457
|
-
if (!anyBaseHasDep) {
|
|
4458
|
-
fail(
|
|
4459
|
-
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4460
|
-
)
|
|
4461
|
-
} else {
|
|
4462
|
-
passFn(
|
|
4463
|
-
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4464
|
-
)
|
|
4498
|
+
if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
|
|
4499
|
+
await checkOverlayRefHpaPdb(root, kustDir, rel, ref, baseDirs, anyBaseHasDep, fail, passFn)
|
|
4465
4500
|
}
|
|
4466
4501
|
}
|
|
4467
4502
|
}
|
|
4468
4503
|
|
|
4504
|
+
/**
|
|
4505
|
+
* @param {string} root нормалізований корінь
|
|
4506
|
+
* @param {string} kustDir каталог kustomization.yaml
|
|
4507
|
+
* @param {string} rel відносний шлях для повідомлень
|
|
4508
|
+
* @param {string} ref посилання з pathRefs
|
|
4509
|
+
* @param {string[]} baseDirs масив base-каталогів
|
|
4510
|
+
* @param {boolean} anyBaseHasDep чи є Deployment у base
|
|
4511
|
+
* @param {(msg: string) => void} fail callback
|
|
4512
|
+
* @param {(msg: string) => void} passFn callback
|
|
4513
|
+
* @returns {Promise<void>}
|
|
4514
|
+
*/
|
|
4515
|
+
async function checkOverlayRefHpaPdb(root, kustDir, rel, ref, baseDirs, anyBaseHasDep, fail, passFn) {
|
|
4516
|
+
const fAbs = resolve(kustDir, ref.trim())
|
|
4517
|
+
if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) return
|
|
4518
|
+
let st
|
|
4519
|
+
try {
|
|
4520
|
+
st = await stat(fAbs)
|
|
4521
|
+
} catch {
|
|
4522
|
+
return
|
|
4523
|
+
}
|
|
4524
|
+
if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) return
|
|
4525
|
+
const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
|
|
4526
|
+
if (fUnderSomeBase) return
|
|
4527
|
+
const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
|
|
4528
|
+
if (!hpaPdb) return
|
|
4529
|
+
if (anyBaseHasDep) {
|
|
4530
|
+
passFn(
|
|
4531
|
+
`${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
|
|
4532
|
+
)
|
|
4533
|
+
} else {
|
|
4534
|
+
fail(
|
|
4535
|
+
`${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
|
|
4536
|
+
)
|
|
4537
|
+
}
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4469
4540
|
/**
|
|
4470
4541
|
* Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
|
|
4471
4542
|
* посилається на base, не додає HPA/PDB без Deployment у base.
|
|
@@ -4480,8 +4551,8 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
|
|
|
4480
4551
|
/** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
|
|
4481
4552
|
const treeFlagsMemo = new Map()
|
|
4482
4553
|
/**
|
|
4483
|
-
* @param {string} kustPath
|
|
4484
|
-
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
|
|
4554
|
+
* @param {string} kustPath абсолютний шлях до kustomization.yaml
|
|
4555
|
+
* @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
|
|
4485
4556
|
*/
|
|
4486
4557
|
const getTreeFlags = kustPath => {
|
|
4487
4558
|
const k = resolve(kustPath)
|
|
@@ -4496,21 +4567,12 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
|
|
|
4496
4567
|
for (const kustAbs of kustFiles) {
|
|
4497
4568
|
const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
4498
4569
|
const kust = await readFirstYamlObject(kustAbs)
|
|
4499
|
-
if (kust
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
|
|
4506
|
-
rootNorm,
|
|
4507
|
-
kustAbs,
|
|
4508
|
-
rel,
|
|
4509
|
-
kust,
|
|
4510
|
-
fail,
|
|
4511
|
-
passFn,
|
|
4512
|
-
getTreeFlags
|
|
4513
|
-
)
|
|
4570
|
+
if (kust !== null) {
|
|
4571
|
+
if (isK8sBaseKustomizationRelPath(rel)) {
|
|
4572
|
+
await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
|
|
4573
|
+
} else {
|
|
4574
|
+
await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(rootNorm, kustAbs, rel, kust, fail, passFn, getTreeFlags)
|
|
4575
|
+
}
|
|
4514
4576
|
}
|
|
4515
4577
|
}
|
|
4516
4578
|
}
|
|
@@ -4786,6 +4848,8 @@ export async function check() {
|
|
|
4786
4848
|
|
|
4787
4849
|
await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
|
|
4788
4850
|
|
|
4851
|
+
await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
|
|
4852
|
+
|
|
4789
4853
|
await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
|
|
4790
4854
|
|
|
4791
4855
|
await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
const FROM_LINE_RE = /^\s*FROM\s+([^\n]+)/i
|
|
15
|
+
const TOKEN_RE = /(?:[^\s"]+|"[^"]*")+/g
|
|
16
|
+
const MIRROR_GCR_RE = /^mirror\.gcr\.io\//i
|
|
17
|
+
const IP_LIKE_RE = /^\d+\.\d+/
|
|
18
|
+
const HOST_PORT_RE = /^\S+:\d+$/
|
|
19
|
+
const DOCKER_IO_PREFIX_RE = /^(docker\.io|index\.docker\.io)\//
|
|
20
|
+
const NEWLINE_SPLIT_RE = /\r?\n/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {string} t — токен образу в лапках або без
|
|
24
|
+
* @returns {string} токен без зовнішніх лапок
|
|
25
|
+
*/
|
|
26
|
+
function stripFromImageQuotes(t) {
|
|
27
|
+
if (t.length >= 2 && (t[0] === '"' || t[0] === "'")) {
|
|
28
|
+
return t.slice(1, -1)
|
|
29
|
+
}
|
|
30
|
+
return t
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
|
|
35
|
+
* Підтримує прапорець `--platform=…` і форму `--platform` + значення.
|
|
36
|
+
* @param {string} line — рядок Dockerfile
|
|
37
|
+
* @returns {string | null} токен образу або null, якщо рядок не `FROM`
|
|
38
|
+
*/
|
|
39
|
+
export function getFromImageToken(line) {
|
|
40
|
+
const withoutComment = line.split('#')[0].trim()
|
|
41
|
+
if (!withoutComment) return null
|
|
42
|
+
const m = withoutComment.match(FROM_LINE_RE)
|
|
43
|
+
if (!m) return null
|
|
44
|
+
const raw = m[1].trim()
|
|
45
|
+
const tokens = raw.match(TOKEN_RE) || []
|
|
46
|
+
let i = 0
|
|
47
|
+
while (i < tokens.length) {
|
|
48
|
+
const t = tokens[i]
|
|
49
|
+
if (t === '--platform' || t.startsWith('--platform=')) {
|
|
50
|
+
if (t.startsWith('--platform=')) {
|
|
51
|
+
i += 1
|
|
52
|
+
} else if (tokens[i + 1] === undefined) {
|
|
53
|
+
i += 1
|
|
54
|
+
} else {
|
|
55
|
+
i += 2
|
|
56
|
+
}
|
|
57
|
+
} else if (t === '--' || t.toUpperCase() === 'AS') {
|
|
58
|
+
break
|
|
59
|
+
} else if (t.startsWith('--') && t.includes('=')) {
|
|
60
|
+
i += 1
|
|
61
|
+
} else if (t.startsWith('--')) {
|
|
62
|
+
i += 1
|
|
63
|
+
} else {
|
|
64
|
+
return stripFromImageQuotes(t)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
|
|
72
|
+
* Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
|
|
73
|
+
* @param {string} imageToken — ref образу (FROM)
|
|
74
|
+
* @returns {boolean} true, якщо схоже на pull з Docker Hub
|
|
75
|
+
*/
|
|
76
|
+
export function isDockerHubStyleImageRef(imageToken) {
|
|
77
|
+
if (!imageToken) return false
|
|
78
|
+
if (MIRROR_GCR_RE.test(imageToken)) return false
|
|
79
|
+
const noDigest = imageToken.split('@')[0] || ''
|
|
80
|
+
if (!noDigest.includes('/')) {
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
const first = noDigest.split('/')[0] || ''
|
|
84
|
+
if (first === 'docker.io' || first === 'index.docker.io') return true
|
|
85
|
+
if (first.includes('.')) return false
|
|
86
|
+
if (first === 'localhost' || IP_LIKE_RE.test(first)) return false
|
|
87
|
+
if (first.includes(':') && HOST_PORT_RE.test(first)) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
|
|
95
|
+
* @param {string} imageToken — ref образу
|
|
96
|
+
* @returns {string} нормалізований шлях репозиторію без тега
|
|
97
|
+
*/
|
|
98
|
+
export function normalizeHubRepoPath(imageToken) {
|
|
99
|
+
let s = (imageToken.split('@')[0] || '').toLowerCase()
|
|
100
|
+
s = s.replace(DOCKER_IO_PREFIX_RE, '')
|
|
101
|
+
if (!s.includes('/')) {
|
|
102
|
+
return `library/${s.split(':')[0]}`
|
|
103
|
+
}
|
|
104
|
+
const lastSl = s.lastIndexOf('/')
|
|
105
|
+
const lastCol = s.lastIndexOf(':')
|
|
106
|
+
if (lastCol > lastSl) {
|
|
107
|
+
s = s.slice(0, lastCol)
|
|
108
|
+
}
|
|
109
|
+
return s
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ (
|
|
113
|
+
new Set(['oven/bun', 'library/alpine', 'library/nginx', 'library/node'])
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
const EXPECTED_MIRROR = /** @type {const} */ ({
|
|
117
|
+
'oven/bun': 'mirror.gcr.io/oven/bun',
|
|
118
|
+
'library/alpine': 'mirror.gcr.io/library/alpine',
|
|
119
|
+
'library/nginx': 'mirror.gcr.io/library/nginx',
|
|
120
|
+
'library/node': 'mirror.gcr.io/library/node'
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Якщо образ тягнеться з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
|
|
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_RE.test(imageToken)) return null
|
|
131
|
+
if (!isDockerHubStyleImageRef(imageToken)) return null
|
|
132
|
+
const norm = normalizeHubRepoPath(imageToken)
|
|
133
|
+
if (!HUB_REPOS_REQUIRING_MIRROR.has(/** @type {keyof typeof EXPECTED_MIRROR} */ (norm))) {
|
|
134
|
+
return null
|
|
135
|
+
}
|
|
136
|
+
return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
|
|
141
|
+
* @param {string} fileContent — повний вміст Dockerfile
|
|
142
|
+
* @returns {string | null} повідомлення з номером рядка або null
|
|
143
|
+
*/
|
|
144
|
+
export function getMirrorGcrHint(fileContent) {
|
|
145
|
+
const lines = fileContent.split(NEWLINE_SPLIT_RE)
|
|
146
|
+
for (const [n, line] of lines.entries()) {
|
|
147
|
+
const image = getFromImageToken(line)
|
|
148
|
+
const expected = getRequiredMirrorGcrImage(image)
|
|
149
|
+
if (expected) {
|
|
150
|
+
return `рядок ${n + 1}: FROM має тягнути ${expected} (замість ${image})`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
@@ -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
|