@nitra/cursor 1.8.13 → 1.8.16
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/k8s.mdc +14 -2
- package/mdc/nginx-default-tpl.mdc +2 -5
- package/package.json +4 -1
- package/scripts/check-k8s.mjs +93 -17
- package/scripts/check-nginx-default-tpl.mjs +336 -27
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.13'
|
|
4
4
|
globs: "**/k8s/**/*.{yaml,yml}"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
@@ -115,9 +115,19 @@ jobs:
|
|
|
115
115
|
|
|
116
116
|
Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-k8s`**, коли в проєкті підключено правило **`k8s`**.
|
|
117
117
|
|
|
118
|
+
## Deployment: `resources`
|
|
119
|
+
|
|
120
|
+
Для **`kind: Deployment`** у кожному контейнері **`spec.template.spec.containers[]`** має бути явне поле **`resources`**. Якщо ліміти та requests ще не задані, додай порожній об'єкт:
|
|
121
|
+
|
|
122
|
+
```yaml
|
|
123
|
+
resources: {}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Так маніфест явно резервує місце під **`requests` / `limits`** і уникає випадкового пропуску секції. **`check k8s`** перевіряє це для кожного YAML-документа **`Deployment`** у файлах під **`k8s`**.
|
|
127
|
+
|
|
118
128
|
## Перевірка
|
|
119
129
|
|
|
120
|
-
**`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою
|
|
130
|
+
**`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** у файлі — наявність **`containers[].resources`** (див. розділ **Deployment: `resources`**). Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту — вручну / **`lint-k8s`**.
|
|
121
131
|
|
|
122
132
|
Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
|
|
123
133
|
|
|
@@ -127,11 +137,13 @@ jobs:
|
|
|
127
137
|
|
|
128
138
|
- Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
|
|
129
139
|
- **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; далі перевіряється **`EXPLICIT_K8S_SCHEMAS`** (`Map`: `apiVersion` + `kind` + `type`, для записів без `type` у маніфесті — третій компонент **`*`**); потім `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree (GitHub Pages), крім рядків явної таблиці (наприклад **InfisicalSecret** — raw на `main`).
|
|
140
|
+
- У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**).
|
|
130
141
|
|
|
131
142
|
## Коли застосовувати (агентам)
|
|
132
143
|
|
|
133
144
|
- Зміни в k8s YAML — після правок **`check k8s`**.
|
|
134
145
|
- Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
|
|
146
|
+
- У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**).
|
|
135
147
|
|
|
136
148
|
## Визначення схеми YAML (канон)
|
|
137
149
|
|
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Правила nginx для статичних файлів
|
|
3
|
-
version: '1.
|
|
3
|
+
version: '1.2'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
# Якщо в проекті є файл default.tpl.conf або default.conf.template
|
|
7
|
-
|
|
8
|
-
Якщо файл називається default.tpl.conf його потрібно перейменувати на default.conf.template
|
|
9
6
|
|
|
10
7
|
default.conf.template повинен виглядати так:
|
|
11
8
|
|
|
@@ -96,7 +93,7 @@ spec:
|
|
|
96
93
|
port: 8080
|
|
97
94
|
```
|
|
98
95
|
|
|
99
|
-
де $PUBLIC_PATH підставляється з ini файлу з dev середовища, а якщо для інших середовищ відрізняється то підставляється в kustomization.
|
|
96
|
+
де $PUBLIC_PATH підставляється з ini файлу з dev середовища, а якщо для інших середовищ відрізняється то підставляється в kustomization.yaml відповідні значення з ini середовища.
|
|
100
97
|
|
|
101
98
|
В $SERVICE_NAME повинно бути вказано ім'я сервісу, яке буде використовуватися в k8s.
|
|
102
99
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.16",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -37,5 +37,8 @@
|
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
39
|
"node": ">=24"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"yaml": "^2.8.3"
|
|
40
43
|
}
|
|
41
44
|
}
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* (окрім `kustomization.yml`); URL схеми за першим документом — kustomization / yannh / datree
|
|
6
6
|
* (datree за замовчуванням: GitHub Pages `https://datreeio.github.io/CRDs-catalog/…`).
|
|
7
7
|
*
|
|
8
|
+
* Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
|
|
9
|
+
* **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
|
|
10
|
+
* порожній **`{}`**).
|
|
11
|
+
*
|
|
8
12
|
* Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
|
|
9
13
|
* **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
|
|
10
14
|
* компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
|
|
@@ -13,6 +17,8 @@
|
|
|
13
17
|
import { readFile } from 'node:fs/promises'
|
|
14
18
|
import { basename, relative } from 'node:path'
|
|
15
19
|
|
|
20
|
+
import { parseAllDocuments } from 'yaml'
|
|
21
|
+
|
|
16
22
|
import { pass } from './utils/pass.mjs'
|
|
17
23
|
import { walkDir } from './utils/walkDir.mjs'
|
|
18
24
|
|
|
@@ -199,6 +205,76 @@ function extractApiVersionAndKind(doc) {
|
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
|
|
210
|
+
* @param {unknown} manifest корінь розпарсеного YAML-документа
|
|
211
|
+
* @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
|
|
212
|
+
*/
|
|
213
|
+
export function deploymentResourcesViolation(manifest) {
|
|
214
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) return null
|
|
215
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
216
|
+
if (rec.kind !== 'Deployment') return null
|
|
217
|
+
const spec = rec.spec
|
|
218
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
|
|
219
|
+
const template = /** @type {Record<string, unknown>} */ (spec).template
|
|
220
|
+
if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template)) return null
|
|
221
|
+
const podSpec = /** @type {Record<string, unknown>} */ (template).spec
|
|
222
|
+
if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
|
|
223
|
+
const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
|
|
224
|
+
if (!Array.isArray(containers)) return null
|
|
225
|
+
|
|
226
|
+
for (const [i, c] of containers.entries()) {
|
|
227
|
+
const label =
|
|
228
|
+
typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
|
|
229
|
+
? c.name
|
|
230
|
+
: `#${i + 1}`
|
|
231
|
+
if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
|
|
232
|
+
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
233
|
+
if (!('resources' in cont)) {
|
|
234
|
+
return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
|
|
235
|
+
}
|
|
236
|
+
const r = cont.resources
|
|
237
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
238
|
+
return `контейнер "${label}": resources має бути об'єктом (наприклад порожній об'єкт у YAML: resources: {})`
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return null
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Парсить усі YAML-документи з тіла файлу й реєструє порушення **`Deployment.resources`**.
|
|
248
|
+
* @param {string} rel відносний шлях (для повідомлень)
|
|
249
|
+
* @param {string} body вміст після modeline
|
|
250
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
251
|
+
*/
|
|
252
|
+
function validateDeploymentResourcesInK8sYaml(rel, body, fail) {
|
|
253
|
+
/** @type {import('yaml').Document[]} */
|
|
254
|
+
let docs
|
|
255
|
+
try {
|
|
256
|
+
docs = parseAllDocuments(body)
|
|
257
|
+
} catch (error) {
|
|
258
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
259
|
+
fail(
|
|
260
|
+
`${rel}: не вдалося розібрати YAML для перевірки Deployment.spec.template.spec.containers[].resources (${msg})`
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const [di, doc] of docs.entries()) {
|
|
266
|
+
if (doc.errors.length > 0) {
|
|
267
|
+
fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
|
|
268
|
+
} else {
|
|
269
|
+
const obj = doc.toJSON()
|
|
270
|
+
const v = deploymentResourcesViolation(obj)
|
|
271
|
+
if (v !== null) {
|
|
272
|
+
fail(`${rel}: Deployment (документ ${di + 1}): ${v}`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
202
278
|
/**
|
|
203
279
|
* Kind для імен файлів yannh/datree: лише літери та цифри, нижній регістр (Service → service, HTTPRoute → httproute).
|
|
204
280
|
* @param {string} kind значення поля kind
|
|
@@ -318,31 +394,31 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
|
|
|
318
394
|
return
|
|
319
395
|
}
|
|
320
396
|
|
|
397
|
+
const body = yamlBodyAfterModeline(lines)
|
|
398
|
+
|
|
321
399
|
if (schemaUrl.startsWith('file:')) {
|
|
322
400
|
pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
|
|
323
|
-
|
|
324
|
-
|
|
401
|
+
} else if (/^https:/iu.test(schemaUrl)) {
|
|
402
|
+
const doc = firstYamlDocument(body)
|
|
403
|
+
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
325
404
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const body = yamlBodyAfterModeline(lines)
|
|
332
|
-
const doc = firstYamlDocument(body)
|
|
333
|
-
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
405
|
+
if (expected === null) {
|
|
406
|
+
fail(`${rel}: ${reason}`)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
334
409
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
410
|
+
if (schemaUrl !== expected) {
|
|
411
|
+
fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
|
|
412
|
+
return
|
|
413
|
+
}
|
|
339
414
|
|
|
340
|
-
|
|
341
|
-
|
|
415
|
+
pass(`${rel}: $schema узгоджено (${reason})`)
|
|
416
|
+
} else {
|
|
417
|
+
fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
|
|
342
418
|
return
|
|
343
419
|
}
|
|
344
420
|
|
|
345
|
-
|
|
421
|
+
validateDeploymentResourcesInK8sYaml(rel, body, fail)
|
|
346
422
|
}
|
|
347
423
|
|
|
348
424
|
/**
|
|
@@ -1,13 +1,261 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Перевіряє
|
|
2
|
+
* Перевіряє nginx-шаблон і супутні файли за правилом nginx-default-tpl.mdc.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Якщо в дереві є **default.conf.template**: канонічні директиви (порт 8080, /healthz, gzip_static,
|
|
5
|
+
* без proxy), поруч **\*.ini** (ключі з ini мають зустрічатися в шаблоні як **$KEY**), у будь-якому
|
|
6
|
+
* Dockerfile — **find** + **gzip** для каталогу `/usr/share/nginx/html` та **envsubst** з
|
|
7
|
+
* **default.conf.template**. Приклад **HTTPRoute** з правила — для рев’ю; автоматична перевірка
|
|
8
|
+
* вимкнена (різні схеми маршрутизації). Функція **`httpRouteMatchesNginxDefaultTpl`** лишається для
|
|
9
|
+
* тестів і майбутнього вузького застосування. VSCode: **extensions.json** та **settings.json** з
|
|
10
|
+
* форматером nginx і **formatOnSave**.
|
|
11
|
+
*
|
|
12
|
+
* У дереві від **cwd** усі **default.tpl.conf** стають **default.conf.template**: перейменування, або
|
|
13
|
+
* якщо **default.conf.template** уже є — він перезаписується вмістом **default.tpl.conf**, після чого
|
|
14
|
+
* **default.tpl.conf** видаляється. Якщо після міграції шаблону немає — перевірка пропускається (0).
|
|
6
15
|
*/
|
|
7
16
|
import { existsSync } from 'node:fs'
|
|
8
|
-
import { readFile } from 'node:fs/promises'
|
|
17
|
+
import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
9
19
|
|
|
20
|
+
import { findDockerfilePaths } from './check-docker.mjs'
|
|
10
21
|
import { pass } from './utils/pass.mjs'
|
|
22
|
+
import { walkDir } from './utils/walkDir.mjs'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Збирає абсолютні шляхи до **default.conf.template** у репозиторії; шлях `tests/fixtures` не обходиться як проєктний шаблон.
|
|
26
|
+
* @param {string} root корінь cwd
|
|
27
|
+
* @returns {Promise<string[]>} відсортовані абсолютні шляхи до шаблонів
|
|
28
|
+
*/
|
|
29
|
+
export async function findDefaultConfTemplatePaths(root) {
|
|
30
|
+
/** @type {string[]} */
|
|
31
|
+
const out = []
|
|
32
|
+
await walkDir(root, p => {
|
|
33
|
+
if (basename(p) !== 'default.conf.template') return
|
|
34
|
+
const rel = relative(root, p).replaceAll('\\', '/')
|
|
35
|
+
if (rel.includes('tests/fixtures/')) return
|
|
36
|
+
out.push(p)
|
|
37
|
+
})
|
|
38
|
+
return out.toSorted((a, b) => a.localeCompare(b))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Знаходить у дереві від `root` усі **default.tpl.conf**. Якщо поруч немає **default.conf.template** —
|
|
43
|
+
* перейменовує файл; якщо є — перезаписує **default.conf.template** вмістом **default.tpl.conf** і видаляє **default.tpl.conf**.
|
|
44
|
+
* @param {string} root корінь обходу (зазвичай cwd репозиторію)
|
|
45
|
+
* @returns {Promise<{ renamed: string[], overwritten: string[] }>} відносні шляхи до обробленого **default.tpl.conf** (для звіту)
|
|
46
|
+
*/
|
|
47
|
+
export async function migrateDefaultTplConfFiles(root) {
|
|
48
|
+
/** @type {string[]} */
|
|
49
|
+
const oldPaths = []
|
|
50
|
+
await walkDir(root, p => {
|
|
51
|
+
if (basename(p) === 'default.tpl.conf') oldPaths.push(p)
|
|
52
|
+
})
|
|
53
|
+
oldPaths.sort((a, b) => a.localeCompare(b))
|
|
54
|
+
|
|
55
|
+
/** @type {string[]} */
|
|
56
|
+
const renamed = []
|
|
57
|
+
/** @type {string[]} */
|
|
58
|
+
const overwritten = []
|
|
59
|
+
|
|
60
|
+
for (const oldPath of oldPaths) {
|
|
61
|
+
const newPath = join(dirname(oldPath), 'default.conf.template')
|
|
62
|
+
const relOld = relative(root, oldPath).replaceAll('\\', '/') || oldPath.replaceAll('\\', '/')
|
|
63
|
+
if (existsSync(newPath)) {
|
|
64
|
+
const body = await readFile(oldPath, 'utf8')
|
|
65
|
+
await writeFile(newPath, body, 'utf8')
|
|
66
|
+
await unlink(oldPath)
|
|
67
|
+
overwritten.push(relOld)
|
|
68
|
+
} else {
|
|
69
|
+
await rename(oldPath, newPath)
|
|
70
|
+
renamed.push(relOld)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { renamed, overwritten }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Імена змінних з ini (рядки KEY=value, без коментарів і порожніх).
|
|
79
|
+
* @param {string} iniText вміст *.ini
|
|
80
|
+
* @returns {string[]} імена змінних у порядку появи
|
|
81
|
+
*/
|
|
82
|
+
export function parseIniVariableNames(iniText) {
|
|
83
|
+
/** @type {string[]} */
|
|
84
|
+
const keys = []
|
|
85
|
+
for (const line of iniText.split(/\r?\n/u)) {
|
|
86
|
+
const t = line.trim()
|
|
87
|
+
if (t !== '' && !t.startsWith('#') && !t.startsWith(';')) {
|
|
88
|
+
const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/u)
|
|
89
|
+
if (m) keys.push(m[1])
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return keys
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Перевіряє вміст **default.conf.template** на відповідність канону з nginx-default-tpl.mdc.
|
|
97
|
+
* @param {string} content текст шаблону
|
|
98
|
+
* @returns {string | null} перше порушення або null
|
|
99
|
+
*/
|
|
100
|
+
export function nginxTemplateViolations(content) {
|
|
101
|
+
/** @type {{ msg: string, ok: (c: string) => boolean }[]} */
|
|
102
|
+
const rules = [
|
|
103
|
+
{ msg: 'відсутнє server_tokens off', ok: c => c.includes('server_tokens off') },
|
|
104
|
+
{ msg: 'відсутнє port_in_redirect off', ok: c => c.includes('port_in_redirect off') },
|
|
105
|
+
{ msg: 'відсутнє client_max_body_size 0', ok: c => c.includes('client_max_body_size 0') },
|
|
106
|
+
{ msg: 'відсутнє client_body_buffer_size 512M', ok: c => c.includes('client_body_buffer_size 512M') },
|
|
107
|
+
{ msg: 'відсутнє listen 8080', ok: c => c.includes('listen 8080') },
|
|
108
|
+
{ msg: 'відсутнє server_name _', ok: c => c.includes('server_name _') },
|
|
109
|
+
{ msg: 'відсутнє access_log off', ok: c => c.includes('access_log off') },
|
|
110
|
+
{ msg: 'відсутнє error_log off', ok: c => c.includes('error_log off') },
|
|
111
|
+
{ msg: 'відсутнє root /usr/share/nginx/html', ok: c => c.includes('root /usr/share/nginx/html') },
|
|
112
|
+
{
|
|
113
|
+
msg: 'location /healthz має повертати healthy (див. nginx-default-tpl.mdc)',
|
|
114
|
+
ok: c => c.includes('/healthz') && (c.includes('healthy') || /return\s+200/u.test(c))
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
msg: 'відсутній location для статики без gzip (gif|jpeg|png|ico|woff2|xlsx) з Cache-Control 31536000',
|
|
118
|
+
ok: c =>
|
|
119
|
+
c.includes('gif|jpe?g|png|ico|woff2|xlsx') &&
|
|
120
|
+
c.includes('31536000') &&
|
|
121
|
+
c.includes('alias /usr/share/nginx/html/')
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
msg: 'відсутній location для svg|js|css|ttf|map|xml|webmanifest|wasm з gzip_static',
|
|
125
|
+
ok: c => c.includes('svg|js|css|ttf|map|xml|webmanifest|wasm')
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
msg: 'gzip_static on має бути принаймні двічі (два location зі стисненням)',
|
|
129
|
+
ok: c => (c.match(/gzip_static\s+on/gu) ?? []).length >= 2
|
|
130
|
+
},
|
|
131
|
+
{ msg: 'відсутнє використання $PUBLIC_PATH у location', ok: c => c.includes('$PUBLIC_PATH') },
|
|
132
|
+
{
|
|
133
|
+
msg: 'відсутні sendfile on; sendfile_max_chunk 512k; tcp_nopush on',
|
|
134
|
+
ok: c => c.includes('sendfile on') && c.includes('sendfile_max_chunk 512k') && c.includes('tcp_nopush on')
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
msg: 'відсутнє try_files $uri $uri/ /index.html =404',
|
|
138
|
+
ok: c => c.includes('try_files $uri $uri/ /index.html =404')
|
|
139
|
+
}
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
for (const { msg, ok } of rules) {
|
|
143
|
+
if (!ok(content)) return msg
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const proxyLike =
|
|
147
|
+
/\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
|
|
148
|
+
if (proxyLike.test(content)) {
|
|
149
|
+
return 'знайдено proxy/fastcgi/grpc — прибери з шаблону, логіку винеси в HTTPRoute (k8s) (див. nginx-default-tpl.mdc)'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Чи HTTPRoute відповідає патерну Exact→RequestRedirect(301, https) + PathPrefix→backendRefs:8080.
|
|
157
|
+
* @param {unknown} manifest корінь YAML-документа
|
|
158
|
+
* @returns {boolean} true, якщо структура збігається з прикладом у nginx-default-tpl.mdc
|
|
159
|
+
*/
|
|
160
|
+
export function httpRouteMatchesNginxDefaultTpl(manifest) {
|
|
161
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) return false
|
|
162
|
+
const m = /** @type {Record<string, unknown>} */ (manifest)
|
|
163
|
+
if (m.kind !== 'HTTPRoute') return false
|
|
164
|
+
const spec = m.spec
|
|
165
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return false
|
|
166
|
+
const rules = /** @type {Record<string, unknown>} */ (spec).rules
|
|
167
|
+
if (!Array.isArray(rules) || rules.length < 2) return false
|
|
168
|
+
|
|
169
|
+
const [first, second] = rules
|
|
170
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) return false
|
|
171
|
+
if (second === null || second === undefined || typeof second !== 'object' || Array.isArray(second)) return false
|
|
172
|
+
|
|
173
|
+
const r0 = /** @type {Record<string, unknown>} */ (first)
|
|
174
|
+
const r1 = /** @type {Record<string, unknown>} */ (second)
|
|
175
|
+
|
|
176
|
+
const matches0 = r0.matches
|
|
177
|
+
const filters0 = r0.filters
|
|
178
|
+
const matches1 = r1.matches
|
|
179
|
+
const backends1 = r1.backendRefs
|
|
180
|
+
|
|
181
|
+
const hasExact =
|
|
182
|
+
Array.isArray(matches0) &&
|
|
183
|
+
matches0.some(x => {
|
|
184
|
+
if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
|
|
185
|
+
return /** @type {Record<string, unknown>} */ (x).path?.type === 'Exact'
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const hasRedirect =
|
|
189
|
+
Array.isArray(filters0) &&
|
|
190
|
+
filters0.some(f => {
|
|
191
|
+
if (f === null || f === undefined || typeof f !== 'object' || Array.isArray(f)) return false
|
|
192
|
+
const fr = /** @type {Record<string, unknown>} */ (f)
|
|
193
|
+
if (fr.type !== 'RequestRedirect') return false
|
|
194
|
+
const rr = fr.requestRedirect
|
|
195
|
+
if (rr === null || rr === undefined || typeof rr !== 'object' || Array.isArray(rr)) return false
|
|
196
|
+
const red = /** @type {Record<string, unknown>} */ (rr)
|
|
197
|
+
const code = red.statusCode
|
|
198
|
+
const okCode = code === 301 || code === '301'
|
|
199
|
+
return red.scheme === 'https' && red.path?.type === 'ReplaceFullPath' && okCode
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const hasPrefix =
|
|
203
|
+
Array.isArray(matches1) &&
|
|
204
|
+
matches1.some(x => {
|
|
205
|
+
if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
|
|
206
|
+
return /** @type {Record<string, unknown>} */ (x).path?.type === 'PathPrefix'
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const has8080 =
|
|
210
|
+
Array.isArray(backends1) &&
|
|
211
|
+
backends1.some(b => {
|
|
212
|
+
if (b === null || b === undefined || typeof b !== 'object' || Array.isArray(b)) return false
|
|
213
|
+
const p = /** @type {Record<string, unknown>} */ (b).port
|
|
214
|
+
return p === 8080 || p === '8080'
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
return hasExact && hasRedirect && hasPrefix && has8080
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Кожен ключ з ini має входити в шаблон як `$KEY` (envsubst).
|
|
222
|
+
* @param {string[]} keys імена змінних
|
|
223
|
+
* @param {string} template вміст default.conf.template
|
|
224
|
+
* @returns {string | null} повідомлення або null
|
|
225
|
+
*/
|
|
226
|
+
export function iniKeysMissingInTemplate(keys, template) {
|
|
227
|
+
for (const k of keys) {
|
|
228
|
+
if (!template.includes(`$${k}`)) {
|
|
229
|
+
return `змінна "${k}" з *.ini не використовується в шаблоні — вилучи її з ini або додай плейсхолдер $${k} (див. nginx-default-tpl.mdc)`
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Чи Dockerfile містить RUN із find/gzip для статики під `/usr/share/nginx/html`.
|
|
237
|
+
* @param {string} dockerfileContent повний текст Dockerfile
|
|
238
|
+
* @returns {boolean} true, якщо знайдено типовий крок стиснення
|
|
239
|
+
*/
|
|
240
|
+
function dockerfileHasGzipStaticPipeline(dockerfileContent) {
|
|
241
|
+
const c = dockerfileContent
|
|
242
|
+
return (
|
|
243
|
+
/\bfind\b/u.test(c) &&
|
|
244
|
+
c.includes('/usr/share/nginx/html') &&
|
|
245
|
+
/\bgzip\b/u.test(c) &&
|
|
246
|
+
c.includes('-k') &&
|
|
247
|
+
/\*\.(?:js|css)/u.test(c)
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Чи Dockerfile містить envsubst для **default.conf.template**.
|
|
253
|
+
* @param {string} dockerfileContent повний текст Dockerfile
|
|
254
|
+
* @returns {boolean} true, якщо є envsubst і посилання на шаблон
|
|
255
|
+
*/
|
|
256
|
+
function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
|
|
257
|
+
return dockerfileContent.includes('envsubst') && dockerfileContent.includes('default.conf.template')
|
|
258
|
+
}
|
|
11
259
|
|
|
12
260
|
/**
|
|
13
261
|
* Перевіряє відповідність проєкту правилам nginx-default-tpl.mdc
|
|
@@ -20,56 +268,117 @@ export async function check() {
|
|
|
20
268
|
exitCode = 1
|
|
21
269
|
}
|
|
22
270
|
|
|
23
|
-
|
|
24
|
-
|
|
271
|
+
const root = process.cwd()
|
|
272
|
+
|
|
273
|
+
const { renamed: tplRenamed, overwritten: tplOverwritten } = await migrateDefaultTplConfFiles(root)
|
|
274
|
+
for (const rel of tplRenamed) {
|
|
275
|
+
pass(`Перейменовано default.tpl.conf → default.conf.template: ${rel}`)
|
|
276
|
+
}
|
|
277
|
+
for (const rel of tplOverwritten) {
|
|
278
|
+
pass(`Перезаписано default.conf.template змістом default.tpl.conf: ${rel}`)
|
|
25
279
|
}
|
|
26
280
|
|
|
27
|
-
const
|
|
28
|
-
|
|
281
|
+
const templates = await findDefaultConfTemplatePaths(root)
|
|
282
|
+
|
|
283
|
+
if (templates.length === 0) {
|
|
284
|
+
pass('Немає default.conf.template — перевірку nginx-default-tpl пропущено')
|
|
285
|
+
return 0
|
|
286
|
+
}
|
|
29
287
|
|
|
30
|
-
|
|
31
|
-
pass(`${found} існує`)
|
|
32
|
-
const content = await readFile(found, 'utf8')
|
|
288
|
+
pass(`Знайдено default.conf.template: ${templates.length}`)
|
|
33
289
|
|
|
34
|
-
|
|
35
|
-
|
|
290
|
+
for (const abs of templates) {
|
|
291
|
+
const rel = relative(root, abs) || abs
|
|
292
|
+
const content = await readFile(abs, 'utf8')
|
|
293
|
+
const v = nginxTemplateViolations(content)
|
|
294
|
+
if (v) {
|
|
295
|
+
fail(`${rel}: ${v}`)
|
|
36
296
|
} else {
|
|
37
|
-
|
|
297
|
+
pass(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
|
|
38
298
|
}
|
|
39
299
|
|
|
40
|
-
|
|
41
|
-
|
|
300
|
+
const dir = dirname(abs)
|
|
301
|
+
let iniNames = []
|
|
302
|
+
try {
|
|
303
|
+
const dirEntries = await readdir(dir)
|
|
304
|
+
iniNames = dirEntries.filter(n => n.endsWith('.ini'))
|
|
305
|
+
} catch {
|
|
306
|
+
iniNames = []
|
|
307
|
+
}
|
|
308
|
+
if (iniNames.length === 0) {
|
|
309
|
+
fail(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
|
|
42
310
|
} else {
|
|
43
|
-
|
|
311
|
+
pass(`${rel}: поруч є *.ini (${iniNames.length})`)
|
|
44
312
|
}
|
|
45
313
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
314
|
+
for (const iniName of iniNames) {
|
|
315
|
+
const iniPath = `${dir}/${iniName}`
|
|
316
|
+
const iniRel = relative(root, iniPath) || iniPath
|
|
317
|
+
let iniRaw
|
|
318
|
+
try {
|
|
319
|
+
iniRaw = await readFile(iniPath, 'utf8')
|
|
320
|
+
} catch (error) {
|
|
321
|
+
fail(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
|
|
322
|
+
iniRaw = null
|
|
323
|
+
}
|
|
324
|
+
if (iniRaw !== null) {
|
|
325
|
+
const keys = parseIniVariableNames(iniRaw)
|
|
326
|
+
const miss = iniKeysMissingInTemplate(keys, content)
|
|
327
|
+
if (miss) {
|
|
328
|
+
fail(`${iniRel}: ${miss}`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
50
331
|
}
|
|
332
|
+
}
|
|
51
333
|
|
|
52
|
-
|
|
53
|
-
|
|
334
|
+
const dockerPaths = await findDockerfilePaths(root)
|
|
335
|
+
if (dockerPaths.length === 0) {
|
|
336
|
+
fail(
|
|
337
|
+
'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
|
|
338
|
+
)
|
|
339
|
+
} else {
|
|
340
|
+
const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
|
|
341
|
+
const gzipOk = bodies.some(body => dockerfileHasGzipStaticPipeline(body))
|
|
342
|
+
const envOk = bodies.some(body => dockerfileHasEnvsSubstTemplate(body))
|
|
343
|
+
if (gzipOk) {
|
|
344
|
+
pass('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
|
|
345
|
+
} else {
|
|
346
|
+
fail('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
|
|
347
|
+
}
|
|
348
|
+
if (envOk) {
|
|
349
|
+
pass('Dockerfile: знайдено envsubst для default.conf.template')
|
|
350
|
+
} else {
|
|
351
|
+
fail('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
|
|
54
352
|
}
|
|
55
353
|
}
|
|
56
354
|
|
|
57
355
|
if (existsSync('.vscode/extensions.json')) {
|
|
58
|
-
const
|
|
356
|
+
const extRaw = await readFile('.vscode/extensions.json', 'utf8')
|
|
357
|
+
const ext = JSON.parse(extRaw)
|
|
59
358
|
if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
|
|
60
359
|
pass('extensions.json містить ahmadalli.vscode-nginx-conf')
|
|
61
360
|
} else {
|
|
62
361
|
fail('extensions.json не містить ahmadalli.vscode-nginx-conf')
|
|
63
362
|
}
|
|
363
|
+
} else {
|
|
364
|
+
fail('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
|
|
64
365
|
}
|
|
65
366
|
|
|
66
367
|
if (existsSync('.vscode/settings.json')) {
|
|
67
|
-
const
|
|
368
|
+
const settingsRaw = await readFile('.vscode/settings.json', 'utf8')
|
|
369
|
+
const s = JSON.parse(settingsRaw)
|
|
370
|
+
if (s['editor.formatOnSave'] === true) {
|
|
371
|
+
pass('settings.json: editor.formatOnSave увімкнено')
|
|
372
|
+
} else {
|
|
373
|
+
fail('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
|
|
374
|
+
}
|
|
68
375
|
if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
|
|
69
|
-
pass('settings.json: nginx
|
|
376
|
+
pass('settings.json: [nginx] defaultFormatter налаштовано')
|
|
70
377
|
} else {
|
|
71
|
-
fail('settings.json: [nginx]
|
|
378
|
+
fail('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
|
|
72
379
|
}
|
|
380
|
+
} else {
|
|
381
|
+
fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
|
|
73
382
|
}
|
|
74
383
|
|
|
75
384
|
return exitCode
|