@nitra/cursor 1.8.152 → 1.8.154
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/auto-rules.md +1 -1
- package/mdc/js-bun-db.mdc +7 -5
- package/mdc/k8s.mdc +21 -1
- package/package.json +1 -1
- package/scripts/auto-rules.mjs +3 -3
- package/scripts/check-js-bun-db.mjs +4 -2
- package/scripts/check-k8s.mjs +458 -1
package/bin/auto-rules.md
CHANGED
|
@@ -22,7 +22,7 @@ js-pino - якщо присутній хоч один js файл, не в мо
|
|
|
22
22
|
|
|
23
23
|
js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
|
|
24
24
|
|
|
25
|
-
js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
|
|
25
|
+
js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
|
|
26
26
|
|
|
27
27
|
k8s - якщо присутня хоч одна директорія k8s
|
|
28
28
|
|
package/mdc/js-bun-db.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.3'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Підтримувані версії баз даних
|
|
@@ -10,12 +10,14 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
|
|
|
10
10
|
|
|
11
11
|
## Заміна на Bun native SQL
|
|
12
12
|
|
|
13
|
-
Якщо в проєкті використовуються бібліотеки `pg` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
|
|
13
|
+
Якщо в проєкті використовуються бібліотеки `pg`, `pg-format` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
|
|
14
14
|
|
|
15
|
-
- Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `mysql`, `mysql2`.
|
|
15
|
+
- Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `pg-format`, `mysql`, `mysql2`.
|
|
16
16
|
- Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
|
|
17
17
|
- Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
|
|
18
18
|
|
|
19
|
+
`pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
|
|
20
|
+
|
|
19
21
|
## Підключення (singleton + env)
|
|
20
22
|
|
|
21
23
|
Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
|
|
@@ -142,9 +144,9 @@ function getUser(id) {
|
|
|
142
144
|
|
|
143
145
|
`new SQL(...)` має створюватись **один раз** на рівні модуля. Bun сам тримає пул (`max`, `idleTimeout`, `maxLifetime`) — окремих `Pool`/`Client` як у `pg` не потрібно.
|
|
144
146
|
|
|
145
|
-
### Не лишати `pg` / `mysql2` поряд із Bun SQL
|
|
147
|
+
### Не лишати `pg` / `pg-format` / `mysql2` поряд із Bun SQL
|
|
146
148
|
|
|
147
|
-
Якщо в коді з'явився `import { sql } from 'bun'`, то `pg` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до
|
|
149
|
+
Якщо в коді з'явився `import { sql } from 'bun'`, то `pg`, `pg-format` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД та ручного форматування поряд із параметризованими template literal.
|
|
148
150
|
|
|
149
151
|
## Перевірка
|
|
150
152
|
|
package/mdc/k8s.mdc
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.26'
|
|
4
4
|
globs: "**/k8s/**/*.yaml"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -266,6 +266,26 @@ data:
|
|
|
266
266
|
|
|
267
267
|
- Після перенесення маніфестів у **`base`** та overlays і перевірки (**`check k8s`**, **`lint-k8s`**) **видали** застарілі файли та директорії, які замінені новою схемою (дубльовані копії, колишні шляхи без Kustomize), щоб у репозиторії не залишалося зайвих або суперечливих маніфестів.
|
|
268
268
|
|
|
269
|
+
### Зміна image — через `images:`, не через `patches[]`
|
|
270
|
+
|
|
271
|
+
Підміну image у Pod-шаблоні Deployment в overlay роби директивою `images:`, а не JSON6902-патчем `op: replace` на `/spec/template/spec/containers/<N>/image`. `images:` — канонічний механізм Kustomize (матчить за іменем образу, не за індексом контейнера, тож стійкий до перестановки).
|
|
272
|
+
|
|
273
|
+
- У **`name`** — те, що **дослівно** стоїть у `image:` у base (або в попередньому шарі) **без тегу**: інакше підміна не спрацює. Тег у `name` зайвий — Kustomize матчить лише за іменем.
|
|
274
|
+
- **`newName`** — кінцеве ім'я образу (як правило, збігається з `name`); `**newTag**` — тег для прода. Якщо `newTag` дорівнює тегу, який вже в base, його можна не вказувати.
|
|
275
|
+
- **`digest`** (`@sha256:…`) у `name` / `newName` не чіпай — це не тег.
|
|
276
|
+
|
|
277
|
+
```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
|
|
278
|
+
images:
|
|
279
|
+
- name: europe-west4-docker.pkg.dev/abie-ua/c/apply-on-invoice-discount
|
|
280
|
+
newName: europe-west4-docker.pkg.dev/abie-ua/c/apply-on-invoice-discount
|
|
281
|
+
newTag: v2025-04-29
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
**`check k8s` автоматично** для кожного `kustomization.yaml`:
|
|
285
|
+
|
|
286
|
+
1. конвертує JSON6902-патч `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`); якщо в `patches[]` залишається лише ця операція — патч прибирається повністю;
|
|
287
|
+
2. чистить існуючий блок `images:` — зрізає `:tag` з `name` і видаляє `newTag`, який збігається з відрізаним тегом.
|
|
288
|
+
|
|
269
289
|
## Ingress → Gateway API (GKE)
|
|
270
290
|
|
|
271
291
|
Якщо в дереві **`k8s`** трапляється маніфест з **`kind: Ingress`**, його потрібно **замінити на Gateway API**, а не залишати Ingress.
|
package/package.json
CHANGED
package/scripts/auto-rules.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
|
|
3
3
|
*
|
|
4
4
|
* Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
|
|
5
|
-
* залежності `mssql` / `pg` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
|
|
5
|
+
* залежності `mssql` / `pg` / `pg-format` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
|
|
6
6
|
* `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
|
|
7
7
|
*
|
|
8
8
|
* Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
|
|
@@ -435,9 +435,9 @@ export async function detectAutoRulesAndSkills({
|
|
|
435
435
|
)
|
|
436
436
|
const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
|
|
437
437
|
const isMonorepo = isMonorepoPackage(packageJsonParsed)
|
|
438
|
-
const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
|
|
438
|
+
const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
|
|
439
439
|
const hasMssqlDependency = depHits.has('mssql')
|
|
440
|
-
const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
|
|
440
|
+
const hasJsBunDbSignal = depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
|
|
441
441
|
|
|
442
442
|
/** @type {string[]} */
|
|
443
443
|
const detectedRules = []
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* Перевіряє правило js-bun-db.mdc.
|
|
3
3
|
*
|
|
4
4
|
* 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
|
|
5
|
-
* бути `pg` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
|
|
5
|
+
* бути `pg`, `pg-format` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
|
|
6
6
|
* (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
|
|
7
|
+
* `pg-format` — ручне форматування SQL через escape; tagged template Bun SQL
|
|
8
|
+
* параметризує значення нативно і не лишає простору для injection.
|
|
7
9
|
*
|
|
8
10
|
* 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
|
|
9
11
|
* перевіряє небезпечні патерни:
|
|
@@ -28,7 +30,7 @@ import {
|
|
|
28
30
|
import { walkDir } from './utils/walkDir.mjs'
|
|
29
31
|
|
|
30
32
|
/** Імена забороненої залежності у будь-якому `package.json`. */
|
|
31
|
-
const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'mysql2'])
|
|
33
|
+
const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'pg-format', 'mysql2'])
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* @param {unknown} v parsed JSON
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -89,6 +89,13 @@
|
|
|
89
89
|
* `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
|
|
90
90
|
* помилка `check k8s` (k8s.mdc).
|
|
91
91
|
*
|
|
92
|
+
* **Images у Kustomize — `images:`, не patch:** для кожного `kustomization.yaml` автоматично:
|
|
93
|
+
* (а) конвертує JSON6902 `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у
|
|
94
|
+
* запис **`images:`** — `name` береться з оригінального `image:` у base (без тегу), `newName` — з patch.value (без тегу),
|
|
95
|
+
* `newTag` — лише якщо тег у patch.value відрізняється від тега в base; якщо `patches[]` після цього порожній — ключ
|
|
96
|
+
* прибирається; (б) чистить існуючий блок **`images:`** — зрізає `:tag` з `name` (digest `@…` не чіпає) і видаляє
|
|
97
|
+
* `newTag`, який збігається з відрізаним тегом.
|
|
98
|
+
*
|
|
92
99
|
* **HPA / PDB тільки з Deployment у шарі base:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
|
|
93
100
|
* дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
|
|
94
101
|
* (рекурсивно), якщо в цьому ж дереві немає документа **`Deployment`** у жодному YAML під **`…/k8s/…/base/`**. У
|
|
@@ -99,7 +106,7 @@ import { existsSync } from 'node:fs'
|
|
|
99
106
|
import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
|
|
100
107
|
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
101
108
|
|
|
102
|
-
import { parseAllDocuments } from 'yaml'
|
|
109
|
+
import { isSeq, parseAllDocuments, parseDocument } from 'yaml'
|
|
103
110
|
|
|
104
111
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
105
112
|
import { walkDir } from './utils/walkDir.mjs'
|
|
@@ -5040,6 +5047,454 @@ async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, pas
|
|
|
5040
5047
|
}
|
|
5041
5048
|
}
|
|
5042
5049
|
|
|
5050
|
+
/**
|
|
5051
|
+
* Розбирає рядок image на ім'я і тег, з виявленням digest.
|
|
5052
|
+
*
|
|
5053
|
+
* - `foo@sha256:…` — `hasDigest: true`, тег не виділяється;
|
|
5054
|
+
* - `localhost:5000/foo` (порт без тегу) — теж без виділення;
|
|
5055
|
+
* - `localhost:5000/foo:tag` — `name: 'localhost:5000/foo'`, `tag: 'tag'`.
|
|
5056
|
+
*
|
|
5057
|
+
* Тег визначається лише по **останній** двокрапці; якщо після неї є `/` — це порт реєстру, не тег.
|
|
5058
|
+
* @param {string} image рядок image
|
|
5059
|
+
* @returns {{ name: string, tag: string | null, hasDigest: boolean }}
|
|
5060
|
+
*/
|
|
5061
|
+
export function splitImageNameTagDigest(image) {
|
|
5062
|
+
if (image.includes('@')) {
|
|
5063
|
+
return { name: image, tag: null, hasDigest: true }
|
|
5064
|
+
}
|
|
5065
|
+
const lastColon = image.lastIndexOf(':')
|
|
5066
|
+
if (lastColon === -1) {
|
|
5067
|
+
return { name: image, tag: null, hasDigest: false }
|
|
5068
|
+
}
|
|
5069
|
+
const after = image.slice(lastColon + 1)
|
|
5070
|
+
if (after === '' || after.includes('/')) {
|
|
5071
|
+
return { name: image, tag: null, hasDigest: false }
|
|
5072
|
+
}
|
|
5073
|
+
return { name: image.slice(0, lastColon), tag: after, hasDigest: false }
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
/**
|
|
5077
|
+
* Розпаковує YAML-скаляр з оточуючими лапками (single або double). Інші стилі (block scalar) — повертає як є.
|
|
5078
|
+
* @param {string} raw сирий рядок-значення без trailing whitespace/comment
|
|
5079
|
+
* @returns {{ unquoted: string, quote: '' | "'" | '"' }}
|
|
5080
|
+
*/
|
|
5081
|
+
function parseQuotedYamlScalar(raw) {
|
|
5082
|
+
if (raw.length >= 2) {
|
|
5083
|
+
const first = raw.charAt(0)
|
|
5084
|
+
const last = raw.charAt(raw.length - 1)
|
|
5085
|
+
if (first === '"' && last === '"') {
|
|
5086
|
+
return { unquoted: raw.slice(1, -1), quote: '"' }
|
|
5087
|
+
}
|
|
5088
|
+
if (first === "'" && last === "'") {
|
|
5089
|
+
return { unquoted: raw.slice(1, -1).replaceAll("''", "'"), quote: "'" }
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
return { unquoted: raw, quote: '' }
|
|
5093
|
+
}
|
|
5094
|
+
|
|
5095
|
+
/**
|
|
5096
|
+
* Загортає скаляр у лапки, повертаючи оригінальний стиль.
|
|
5097
|
+
* @param {string} value значення без оточуючих лапок
|
|
5098
|
+
* @param {'' | "'" | '"'} quote стиль лапок
|
|
5099
|
+
* @returns {string} рядок-скаляр для запису назад у YAML
|
|
5100
|
+
*/
|
|
5101
|
+
function requoteYamlScalar(value, quote) {
|
|
5102
|
+
if (quote === '"') return `"${value}"`
|
|
5103
|
+
if (quote === "'") return `'${value.replaceAll("'", "''")}'`
|
|
5104
|
+
return value
|
|
5105
|
+
}
|
|
5106
|
+
|
|
5107
|
+
/** Regex: рядок верхнього рівня з ключем `images:` (без значення в тому ж рядку). */
|
|
5108
|
+
const KUSTOMIZATION_IMAGES_KEY_RE = /^images:\s*(?:#.*)?$/u
|
|
5109
|
+
/** Regex: початок елемента масиву (`-` з відступом). Групує сам відступ перед `-`. */
|
|
5110
|
+
const KUSTOMIZATION_LIST_ITEM_RE = /^(\s*)-\s/u
|
|
5111
|
+
/** Regex: значення поля (name / newName / newTag) у рядку, з опційним `- ` префіксом. */
|
|
5112
|
+
const KUSTOMIZATION_IMAGE_FIELD_RE = /^(\s*(?:-\s+)?)(name|newName|newTag):(\s+)(\S.*?)(\s*(?:#.*)?)$/u
|
|
5113
|
+
|
|
5114
|
+
/**
|
|
5115
|
+
* Автофікс блоку `images:` у kustomization.yaml: зрізає `:tag` з `name` (digest `@…` не чіпає)
|
|
5116
|
+
* і видаляє `newTag`, який збігається зі зрізаним тегом. Працює рядково, зберігаючи коментарі
|
|
5117
|
+
* й форматування.
|
|
5118
|
+
* @param {string} raw вміст файлу
|
|
5119
|
+
* @returns {{ changed: boolean, content: string }}
|
|
5120
|
+
*/
|
|
5121
|
+
export function cleanupKustomizationImagesInYamlText(raw) {
|
|
5122
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n'
|
|
5123
|
+
const lines = raw.split(YAML_LINE_SPLIT_RE)
|
|
5124
|
+
|
|
5125
|
+
let imagesStart = -1
|
|
5126
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5127
|
+
if (KUSTOMIZATION_IMAGES_KEY_RE.test(lines[i])) {
|
|
5128
|
+
imagesStart = i + 1
|
|
5129
|
+
break
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
if (imagesStart === -1) return { changed: false, content: raw }
|
|
5133
|
+
|
|
5134
|
+
let imagesEnd = lines.length
|
|
5135
|
+
for (let i = imagesStart; i < lines.length; i++) {
|
|
5136
|
+
const l = lines[i]
|
|
5137
|
+
if (l === '' || /^\s/u.test(l) || /^#/u.test(l)) continue
|
|
5138
|
+
imagesEnd = i
|
|
5139
|
+
break
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
/** @type {Array<{ start: number, end: number }>} */
|
|
5143
|
+
const entries = []
|
|
5144
|
+
let curStart = -1
|
|
5145
|
+
for (let i = imagesStart; i < imagesEnd; i++) {
|
|
5146
|
+
if (KUSTOMIZATION_LIST_ITEM_RE.test(lines[i])) {
|
|
5147
|
+
if (curStart >= 0) entries.push({ start: curStart, end: i })
|
|
5148
|
+
curStart = i
|
|
5149
|
+
}
|
|
5150
|
+
}
|
|
5151
|
+
if (curStart >= 0) entries.push({ start: curStart, end: imagesEnd })
|
|
5152
|
+
|
|
5153
|
+
/** @type {Map<number, string>} */
|
|
5154
|
+
const replacements = new Map()
|
|
5155
|
+
/** @type {Set<number>} */
|
|
5156
|
+
const removals = new Set()
|
|
5157
|
+
let changed = false
|
|
5158
|
+
|
|
5159
|
+
for (const { start, end } of entries) {
|
|
5160
|
+
/** @type {string | null} */
|
|
5161
|
+
let strippedTag = null
|
|
5162
|
+
let nameProcessed = false
|
|
5163
|
+
/** @type {{ lineIdx: number, value: string } | null} */
|
|
5164
|
+
let newTagInfo = null
|
|
5165
|
+
let newTagProcessed = false
|
|
5166
|
+
|
|
5167
|
+
for (let i = start; i < end; i++) {
|
|
5168
|
+
const m = lines[i].match(KUSTOMIZATION_IMAGE_FIELD_RE)
|
|
5169
|
+
if (m === null) continue
|
|
5170
|
+
const [, prefix, key, sep, valueRaw, trailing] = m
|
|
5171
|
+
if (key === 'name' && !nameProcessed) {
|
|
5172
|
+
nameProcessed = true
|
|
5173
|
+
const { unquoted, quote } = parseQuotedYamlScalar(valueRaw)
|
|
5174
|
+
const split = splitImageNameTagDigest(unquoted)
|
|
5175
|
+
if (split.tag !== null) {
|
|
5176
|
+
const newLine = `${prefix}name:${sep}${requoteYamlScalar(split.name, quote)}${trailing}`
|
|
5177
|
+
if (newLine !== lines[i]) {
|
|
5178
|
+
replacements.set(i, newLine)
|
|
5179
|
+
changed = true
|
|
5180
|
+
}
|
|
5181
|
+
strippedTag = split.tag
|
|
5182
|
+
}
|
|
5183
|
+
} else if (key === 'newTag' && !newTagProcessed) {
|
|
5184
|
+
newTagProcessed = true
|
|
5185
|
+
const { unquoted } = parseQuotedYamlScalar(valueRaw)
|
|
5186
|
+
newTagInfo = { lineIdx: i, value: unquoted }
|
|
5187
|
+
}
|
|
5188
|
+
}
|
|
5189
|
+
|
|
5190
|
+
if (newTagInfo !== null && strippedTag !== null && newTagInfo.value === strippedTag) {
|
|
5191
|
+
removals.add(newTagInfo.lineIdx)
|
|
5192
|
+
changed = true
|
|
5193
|
+
}
|
|
5194
|
+
}
|
|
5195
|
+
|
|
5196
|
+
if (!changed) return { changed: false, content: raw }
|
|
5197
|
+
|
|
5198
|
+
/** @type {string[]} */
|
|
5199
|
+
const out = []
|
|
5200
|
+
for (let i = 0; i < lines.length; i++) {
|
|
5201
|
+
if (removals.has(i)) continue
|
|
5202
|
+
out.push(replacements.has(i) ? replacements.get(i) : lines[i])
|
|
5203
|
+
}
|
|
5204
|
+
return { changed: true, content: out.join(eol) }
|
|
5205
|
+
}
|
|
5206
|
+
|
|
5207
|
+
/** Regex: JSON6902 path для image окремого контейнера у Pod-шаблоні Deployment. */
|
|
5208
|
+
const KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE = /^\/spec\/template\/spec\/containers\/(\d+)\/image$/u
|
|
5209
|
+
|
|
5210
|
+
/**
|
|
5211
|
+
* Якщо `patchObj` — JSON6902 з єдиною операцією `replace` на шляху image-контейнера у `Deployment`,
|
|
5212
|
+
* повертає `{ deployName, containerIndex, newImage }`. Інакше null.
|
|
5213
|
+
* @param {unknown} patchObj елемент масиву `patches[]`
|
|
5214
|
+
* @returns {{ deployName: string, containerIndex: number, newImage: string } | null}
|
|
5215
|
+
*/
|
|
5216
|
+
export function imageReplaceDeploymentPatchInfo(patchObj) {
|
|
5217
|
+
if (patchObj === null || typeof patchObj !== 'object' || Array.isArray(patchObj)) return null
|
|
5218
|
+
const pr = /** @type {Record<string, unknown>} */ (patchObj)
|
|
5219
|
+
const target = pr.target
|
|
5220
|
+
if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
|
|
5221
|
+
const t = /** @type {Record<string, unknown>} */ (target)
|
|
5222
|
+
if (t.kind !== 'Deployment') return null
|
|
5223
|
+
if (typeof t.name !== 'string' || t.name.trim() === '') return null
|
|
5224
|
+
if (typeof pr.patch !== 'string') return null
|
|
5225
|
+
|
|
5226
|
+
let parsedArr
|
|
5227
|
+
try {
|
|
5228
|
+
for (const d of parseAllDocuments(pr.patch.trim())) {
|
|
5229
|
+
if (d.errors.length === 0) {
|
|
5230
|
+
const j = d.toJSON()
|
|
5231
|
+
if (Array.isArray(j)) {
|
|
5232
|
+
parsedArr = j
|
|
5233
|
+
break
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
} catch {
|
|
5238
|
+
return null
|
|
5239
|
+
}
|
|
5240
|
+
if (!Array.isArray(parsedArr) || parsedArr.length !== 1) return null
|
|
5241
|
+
const op = parsedArr[0]
|
|
5242
|
+
if (op === null || typeof op !== 'object' || Array.isArray(op)) return null
|
|
5243
|
+
const oo = /** @type {Record<string, unknown>} */ (op)
|
|
5244
|
+
if (typeof oo.op !== 'string' || oo.op.toLowerCase() !== 'replace') return null
|
|
5245
|
+
if (typeof oo.path !== 'string') return null
|
|
5246
|
+
const m = oo.path.match(KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE)
|
|
5247
|
+
if (m === null) return null
|
|
5248
|
+
if (typeof oo.value !== 'string' || oo.value.trim() === '') return null
|
|
5249
|
+
|
|
5250
|
+
return {
|
|
5251
|
+
deployName: t.name.trim(),
|
|
5252
|
+
containerIndex: Number(m[1]),
|
|
5253
|
+
newImage: oo.value.trim()
|
|
5254
|
+
}
|
|
5255
|
+
}
|
|
5256
|
+
|
|
5257
|
+
/**
|
|
5258
|
+
* Шукає `Deployment.spec.template.spec.containers[N].image` у YAML-файлі.
|
|
5259
|
+
* @param {string} absPath абсолютний шлях до YAML-файлу
|
|
5260
|
+
* @param {string} deployName ім'я Deployment
|
|
5261
|
+
* @param {number} containerIndex індекс контейнера
|
|
5262
|
+
* @returns {Promise<string | null>} рядок image або null
|
|
5263
|
+
*/
|
|
5264
|
+
async function findDeploymentContainerImageInFile(absPath, deployName, containerIndex) {
|
|
5265
|
+
const raw = await tryReadFileUtf8(absPath)
|
|
5266
|
+
if (raw === undefined) return null
|
|
5267
|
+
const docs = tryParseAllYamlDocs(raw)
|
|
5268
|
+
if (docs === undefined) return null
|
|
5269
|
+
for (const d of docs) {
|
|
5270
|
+
if (d.errors.length !== 0) continue
|
|
5271
|
+
const o = d.toJSON()
|
|
5272
|
+
if (o === null || typeof o !== 'object' || Array.isArray(o)) continue
|
|
5273
|
+
const oo = /** @type {Record<string, unknown>} */ (o)
|
|
5274
|
+
if (oo.kind !== 'Deployment') continue
|
|
5275
|
+
const meta = oo.metadata
|
|
5276
|
+
if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) continue
|
|
5277
|
+
if (/** @type {Record<string, unknown>} */ (meta).name !== deployName) continue
|
|
5278
|
+
const spec = oo.spec
|
|
5279
|
+
if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) continue
|
|
5280
|
+
const tmpl = /** @type {Record<string, unknown>} */ (spec).template
|
|
5281
|
+
if (tmpl === null || typeof tmpl !== 'object' || Array.isArray(tmpl)) continue
|
|
5282
|
+
const podSpec = /** @type {Record<string, unknown>} */ (tmpl).spec
|
|
5283
|
+
if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) continue
|
|
5284
|
+
const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
|
|
5285
|
+
if (!Array.isArray(containers) || containerIndex < 0 || containerIndex >= containers.length) continue
|
|
5286
|
+
const c = containers[containerIndex]
|
|
5287
|
+
if (c === null || typeof c !== 'object' || Array.isArray(c)) continue
|
|
5288
|
+
const img = /** @type {Record<string, unknown>} */ (c).image
|
|
5289
|
+
if (typeof img === 'string' && img.trim() !== '') return img.trim()
|
|
5290
|
+
}
|
|
5291
|
+
return null
|
|
5292
|
+
}
|
|
5293
|
+
|
|
5294
|
+
/**
|
|
5295
|
+
* Рекурсивно проходить дерево kustomization (resources / bases / components / crds), шукаючи
|
|
5296
|
+
* `Deployment` із заданим іменем; повертає image потрібного контейнера або null, якщо не знайдено.
|
|
5297
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml (поточний шар)
|
|
5298
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
5299
|
+
* @param {string} deployName ім'я Deployment
|
|
5300
|
+
* @param {number} containerIndex індекс контейнера
|
|
5301
|
+
* @param {Set<string>} visited нормалізовані відвідані kustomization.yaml
|
|
5302
|
+
* @returns {Promise<string | null>} image або null
|
|
5303
|
+
*/
|
|
5304
|
+
async function walkKustomizationForDeploymentImage(kustAbs, rootNorm, deployName, containerIndex, visited) {
|
|
5305
|
+
const norm = resolve(kustAbs)
|
|
5306
|
+
if (visited.has(norm)) return null
|
|
5307
|
+
visited.add(norm)
|
|
5308
|
+
|
|
5309
|
+
const obj = await readFirstYamlObject(norm)
|
|
5310
|
+
if (obj === null) return null
|
|
5311
|
+
const kustDir = dirname(norm)
|
|
5312
|
+
const refs = pathsFromKustomizationObject(obj)
|
|
5313
|
+
|
|
5314
|
+
for (const ref of refs) {
|
|
5315
|
+
if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') continue
|
|
5316
|
+
const resolved = resolve(kustDir, ref.trim())
|
|
5317
|
+
if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) continue
|
|
5318
|
+
let st
|
|
5319
|
+
try {
|
|
5320
|
+
st = await stat(resolved)
|
|
5321
|
+
} catch {
|
|
5322
|
+
continue
|
|
5323
|
+
}
|
|
5324
|
+
if (st.isFile() && YAML_EXTENSION_RE.test(resolved)) {
|
|
5325
|
+
const img = await findDeploymentContainerImageInFile(resolved, deployName, containerIndex)
|
|
5326
|
+
if (img !== null) return img
|
|
5327
|
+
} else if (st.isDirectory()) {
|
|
5328
|
+
const childK = join(resolved, 'kustomization.yaml')
|
|
5329
|
+
if (existsSync(childK)) {
|
|
5330
|
+
const img = await walkKustomizationForDeploymentImage(childK, rootNorm, deployName, containerIndex, visited)
|
|
5331
|
+
if (img !== null) return img
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
}
|
|
5335
|
+
return null
|
|
5336
|
+
}
|
|
5337
|
+
|
|
5338
|
+
/**
|
|
5339
|
+
* Конвертує JSON6902 image-replace patches у `images:` для одного kustomization.yaml.
|
|
5340
|
+
*
|
|
5341
|
+
* Алгоритм:
|
|
5342
|
+
*
|
|
5343
|
+
* 1. Читає файл, парсить як **Document** (yaml lib), щоб максимально зберегти форматування.
|
|
5344
|
+
* 2. Для кожного `patches[i]` з `target.kind: Deployment` і єдиною операцією
|
|
5345
|
+
* `op: replace` на `path: /spec/template/spec/containers/N/image` шукає оригінальний image
|
|
5346
|
+
* через `walkKustomizationForDeploymentImage` (resources → recursively).
|
|
5347
|
+
* 3. Будує `images:` запис: `name = base_image_без_тегу/digest`, `newName = patch_value_без_тегу`,
|
|
5348
|
+
* `newTag = patch_value_тег`, **якщо** він відрізняється від тега base.
|
|
5349
|
+
* 4. Видаляє відповідні patches; якщо `patches:` стає порожнім — видаляє ключ.
|
|
5350
|
+
* 5. Записує файл назад через `Document.toString()`.
|
|
5351
|
+
* @param {string} kustAbs абсолютний шлях до kustomization.yaml
|
|
5352
|
+
* @param {string} rootNorm нормалізований корінь репо
|
|
5353
|
+
* @returns {Promise<{ changed: boolean, content?: string, errors: string[] }>}
|
|
5354
|
+
*/
|
|
5355
|
+
export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNorm) {
|
|
5356
|
+
const raw = await tryReadFileUtf8(kustAbs)
|
|
5357
|
+
if (raw === undefined) return { changed: false, errors: [] }
|
|
5358
|
+
|
|
5359
|
+
let doc
|
|
5360
|
+
try {
|
|
5361
|
+
doc = parseDocument(raw)
|
|
5362
|
+
} catch {
|
|
5363
|
+
return { changed: false, errors: [] }
|
|
5364
|
+
}
|
|
5365
|
+
if (doc.errors.length > 0) return { changed: false, errors: [] }
|
|
5366
|
+
|
|
5367
|
+
const obj = doc.toJSON()
|
|
5368
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
5369
|
+
return { changed: false, errors: [] }
|
|
5370
|
+
}
|
|
5371
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
5372
|
+
if (rec.kind !== 'Kustomization') return { changed: false, errors: [] }
|
|
5373
|
+
if (typeof rec.apiVersion !== 'string' || !rec.apiVersion.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) {
|
|
5374
|
+
return { changed: false, errors: [] }
|
|
5375
|
+
}
|
|
5376
|
+
if (!Array.isArray(rec.patches)) return { changed: false, errors: [] }
|
|
5377
|
+
|
|
5378
|
+
/** @type {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} */
|
|
5379
|
+
const candidates = []
|
|
5380
|
+
for (const [i, p] of rec.patches.entries()) {
|
|
5381
|
+
const info = imageReplaceDeploymentPatchInfo(p)
|
|
5382
|
+
if (info !== null) candidates.push({ index: i, info })
|
|
5383
|
+
}
|
|
5384
|
+
if (candidates.length === 0) return { changed: false, errors: [] }
|
|
5385
|
+
|
|
5386
|
+
/** @type {Array<{ index: number, name: string, newName: string, newTag: string | null }>} */
|
|
5387
|
+
const conversions = []
|
|
5388
|
+
/** @type {string[]} */
|
|
5389
|
+
const errors = []
|
|
5390
|
+
|
|
5391
|
+
for (const { index, info } of candidates) {
|
|
5392
|
+
/** @type {Set<string>} */
|
|
5393
|
+
const visited = new Set()
|
|
5394
|
+
const baseImage = await walkKustomizationForDeploymentImage(
|
|
5395
|
+
kustAbs,
|
|
5396
|
+
rootNorm,
|
|
5397
|
+
info.deployName,
|
|
5398
|
+
info.containerIndex,
|
|
5399
|
+
visited
|
|
5400
|
+
)
|
|
5401
|
+
if (baseImage === null) {
|
|
5402
|
+
errors.push(
|
|
5403
|
+
`patches[${index}]: не знайдено Deployment ${info.deployName}.containers[${info.containerIndex}].image у дереві resources — конвертацію патча в images: пропущено (k8s.mdc)`
|
|
5404
|
+
)
|
|
5405
|
+
continue
|
|
5406
|
+
}
|
|
5407
|
+
const baseSplit = splitImageNameTagDigest(baseImage)
|
|
5408
|
+
if (baseSplit.hasDigest) {
|
|
5409
|
+
errors.push(
|
|
5410
|
+
`patches[${index}]: base image для ${info.deployName} містить digest (${baseImage}) — автоконвертацію патча пропущено (k8s.mdc)`
|
|
5411
|
+
)
|
|
5412
|
+
continue
|
|
5413
|
+
}
|
|
5414
|
+
const newSplit = splitImageNameTagDigest(info.newImage)
|
|
5415
|
+
if (newSplit.hasDigest) {
|
|
5416
|
+
errors.push(
|
|
5417
|
+
`patches[${index}]: значення патча для ${info.deployName} містить digest (${info.newImage}) — автоконвертацію пропущено (k8s.mdc)`
|
|
5418
|
+
)
|
|
5419
|
+
continue
|
|
5420
|
+
}
|
|
5421
|
+
const finalNewTag = newSplit.tag !== null && newSplit.tag !== baseSplit.tag ? newSplit.tag : null
|
|
5422
|
+
conversions.push({
|
|
5423
|
+
index,
|
|
5424
|
+
name: baseSplit.name,
|
|
5425
|
+
newName: newSplit.name,
|
|
5426
|
+
newTag: finalNewTag
|
|
5427
|
+
})
|
|
5428
|
+
}
|
|
5429
|
+
|
|
5430
|
+
if (conversions.length === 0) return { changed: false, errors }
|
|
5431
|
+
|
|
5432
|
+
const patchesNode = doc.get('patches', true)
|
|
5433
|
+
if (!isSeq(patchesNode)) return { changed: false, errors }
|
|
5434
|
+
|
|
5435
|
+
const removeIdx = new Set(conversions.map(c => c.index))
|
|
5436
|
+
for (let i = patchesNode.items.length - 1; i >= 0; i--) {
|
|
5437
|
+
if (removeIdx.has(i)) patchesNode.delete(i)
|
|
5438
|
+
}
|
|
5439
|
+
if (patchesNode.items.length === 0) {
|
|
5440
|
+
doc.delete('patches')
|
|
5441
|
+
}
|
|
5442
|
+
|
|
5443
|
+
let imagesNode = doc.get('images', true)
|
|
5444
|
+
if (!isSeq(imagesNode)) {
|
|
5445
|
+
imagesNode = doc.createNode([])
|
|
5446
|
+
doc.set('images', imagesNode)
|
|
5447
|
+
}
|
|
5448
|
+
|
|
5449
|
+
for (const { name, newName, newTag } of conversions) {
|
|
5450
|
+
const entry = newTag === null ? { name, newName } : { name, newName, newTag }
|
|
5451
|
+
imagesNode.add(doc.createNode(entry))
|
|
5452
|
+
}
|
|
5453
|
+
|
|
5454
|
+
const content = doc.toString()
|
|
5455
|
+
if (content === raw) return { changed: false, errors }
|
|
5456
|
+
return { changed: true, content, errors }
|
|
5457
|
+
}
|
|
5458
|
+
|
|
5459
|
+
/**
|
|
5460
|
+
* Прохід для всіх `kustomization.yaml`: конвертує image-replace patches у `images:`,
|
|
5461
|
+
* потім чистить `images:` (зрізає теги в `name`, видаляє надлишкові `newTag`).
|
|
5462
|
+
* @param {string} root корінь репо
|
|
5463
|
+
* @param {string[]} yamlFilesAbs всі yaml під k8s
|
|
5464
|
+
* @param {(msg: string) => void} fail колбек повідомлення про помилку
|
|
5465
|
+
* @param {(msg: string) => void} pass колбек успішного повідомлення
|
|
5466
|
+
* @returns {Promise<void>}
|
|
5467
|
+
*/
|
|
5468
|
+
async function autofixKustomizationImagesYaml(root, yamlFilesAbs, fail, pass) {
|
|
5469
|
+
const rootNorm = resolve(root)
|
|
5470
|
+
const kusts = yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')
|
|
5471
|
+
for (const kustAbs of kusts) {
|
|
5472
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
5473
|
+
try {
|
|
5474
|
+
const r = await convertImagePatchesToImagesInKustomization(kustAbs, rootNorm)
|
|
5475
|
+
for (const err of r.errors) fail(`${rel}: ${err}`)
|
|
5476
|
+
if (r.changed && r.content !== undefined) {
|
|
5477
|
+
await writeFile(kustAbs, r.content, 'utf8')
|
|
5478
|
+
pass(`${rel}: image-replace patch(es) конвертовано в images: (k8s.mdc)`)
|
|
5479
|
+
}
|
|
5480
|
+
} catch (error) {
|
|
5481
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
5482
|
+
fail(`${rel}: не вдалося конвертувати image-replace patches → images: (${msg})`)
|
|
5483
|
+
}
|
|
5484
|
+
try {
|
|
5485
|
+
const raw = await readFile(kustAbs, 'utf8')
|
|
5486
|
+
const r = cleanupKustomizationImagesInYamlText(raw)
|
|
5487
|
+
if (r.changed) {
|
|
5488
|
+
await writeFile(kustAbs, r.content, 'utf8')
|
|
5489
|
+
pass(`${rel}: images: cleanup — зрізано :tag з name й видалено надлишкове newTag (k8s.mdc)`)
|
|
5490
|
+
}
|
|
5491
|
+
} catch (error) {
|
|
5492
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
5493
|
+
fail(`${rel}: не вдалося очистити images: (${msg})`)
|
|
5494
|
+
}
|
|
5495
|
+
}
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5043
5498
|
/**
|
|
5044
5499
|
* Перевіряє відповідність проєкту правилам k8s.mdc.
|
|
5045
5500
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -5063,6 +5518,8 @@ export async function check() {
|
|
|
5063
5518
|
|
|
5064
5519
|
pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
|
|
5065
5520
|
|
|
5521
|
+
await autofixKustomizationImagesYaml(root, yamlFiles, fail, pass)
|
|
5522
|
+
|
|
5066
5523
|
assertNoForbiddenK8sDevPaths(yamlFiles, root, fail)
|
|
5067
5524
|
|
|
5068
5525
|
const kustomizeManagedRel = await collectKustomizeManagedRelPaths(root, yamlFiles)
|