@nitra/cursor 1.8.13 → 1.8.18
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/js-lint.mdc +3 -5
- package/mdc/k8s.mdc +14 -2
- package/mdc/nginx-default-tpl.mdc +2 -5
- package/package.json +4 -1
- package/scripts/check-js-lint.mjs +131 -40
- package/scripts/check-k8s.mjs +95 -17
- package/scripts/check-nginx-default-tpl.mjs +338 -27
package/mdc/js-lint.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Перевірка JavaScript коду
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.9'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
**oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`:** `oxlint` (без `bunx`), **`bunx eslint`**, **`bunx jscpd`**; у CI — `bunx oxlint` / `bunx eslint` / `bunx jscpd`. Без **prettier** і **@nitra/prettier-config**. Достатньо **`@nitra/eslint-config`** у devDependencies; пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
|
|
@@ -38,7 +38,7 @@ version: '1.8'
|
|
|
38
38
|
"exitCode": 1,
|
|
39
39
|
"reporters": ["console"],
|
|
40
40
|
"minLines": 25,
|
|
41
|
-
"ignore": []
|
|
41
|
+
"ignore": ["**/dist/**",]
|
|
42
42
|
}
|
|
43
43
|
```
|
|
44
44
|
|
|
@@ -132,9 +132,7 @@ export default [
|
|
|
132
132
|
|
|
133
133
|
## Тести
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
**Код:** синтаксис Node **24+**, **top level await**.
|
|
135
|
+
Проєкт має бути покритий unit-тестами (**Bun test**). Код: синтаксис Node **24+**, **top level await** (узгоджено з `engines.node` у `package.json`).
|
|
138
136
|
|
|
139
137
|
## Перевірка
|
|
140
138
|
|
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.18",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"scripts": {
|
|
36
36
|
"test": "bun test tests"
|
|
37
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"yaml": "^2.8.3"
|
|
40
|
+
},
|
|
38
41
|
"engines": {
|
|
39
42
|
"node": ">=24"
|
|
40
43
|
}
|
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Перевіряє лінт JavaScript за правилом js-lint.mdc.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Канонічний `lint-js`, flat ESLint з getConfig і ignore для auto-imports, рекомендації VSCode,
|
|
5
|
+
* `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow `lint-js.yml` (checkout@v6,
|
|
6
|
+
* setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24. Дубль перевірки JS у `lint.yml` —
|
|
7
|
+
* заборонено.
|
|
6
8
|
*/
|
|
7
9
|
import { existsSync } from 'node:fs'
|
|
8
10
|
import { readFile } from 'node:fs/promises'
|
|
9
11
|
|
|
10
12
|
import { pass } from './utils/pass.mjs'
|
|
11
13
|
|
|
14
|
+
/** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
|
|
15
|
+
export const CANONICAL_LINT_JS = 'oxlint --fix && bunx eslint --fix . && bunx jscpd .'
|
|
16
|
+
|
|
17
|
+
/** Мінімальні рекомендації розширень редактора з js-lint.mdc (eslint, oxlint, GA). */
|
|
18
|
+
export const REQUIRED_VSCODE_EXTENSIONS = ['dbaeumer.vscode-eslint', 'github.vscode-github-actions', 'oxc.oxc-vscode']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Нормалізує рядок скрипта для порівняння (зайві пробіли).
|
|
22
|
+
* @param {string} s вихідний рядок скрипта `lint-js`
|
|
23
|
+
* @returns {string} рядок без зайвих пробілів на краях і з одиничними пробілами всередині
|
|
24
|
+
*/
|
|
25
|
+
export function normalizeLintJsScript(s) {
|
|
26
|
+
return String(s).trim().replaceAll(/\s+/gu, ' ')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Чи рядок `lint-js` збігається з каноном і без `bunx oxlint`.
|
|
31
|
+
* @param {string} script значення `scripts.lint-js` з package.json
|
|
32
|
+
* @returns {boolean} true, якщо рядок канонічний
|
|
33
|
+
*/
|
|
34
|
+
export function isCanonicalLintJs(script) {
|
|
35
|
+
const n = normalizeLintJsScript(script)
|
|
36
|
+
if (n.includes('bunx oxlint')) return false
|
|
37
|
+
return n === CANONICAL_LINT_JS
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
/**
|
|
13
41
|
* Перевіряє відповідність проєкту правилам js-lint.mdc
|
|
14
42
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -20,12 +48,34 @@ export async function check() {
|
|
|
20
48
|
exitCode = 1
|
|
21
49
|
}
|
|
22
50
|
|
|
51
|
+
let eslintPath = ''
|
|
23
52
|
if (existsSync('eslint.config.js')) {
|
|
53
|
+
eslintPath = 'eslint.config.js'
|
|
24
54
|
pass('eslint.config.js існує')
|
|
25
55
|
} else if (existsSync('eslint.config.mjs')) {
|
|
56
|
+
eslintPath = 'eslint.config.mjs'
|
|
26
57
|
pass('eslint.config.mjs існує')
|
|
27
58
|
} else {
|
|
28
|
-
fail('Відсутній eslint.config.js —
|
|
59
|
+
fail('Відсутній eslint.config.js або eslint.config.mjs — flat config з getConfig (js-lint.mdc)')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (eslintPath) {
|
|
63
|
+
const eslintRaw = await readFile(eslintPath, 'utf8')
|
|
64
|
+
if (eslintRaw.includes('getConfig')) {
|
|
65
|
+
pass(`${eslintPath}: містить getConfig`)
|
|
66
|
+
} else {
|
|
67
|
+
fail(`${eslintPath}: потрібен виклик getConfig (js-lint.mdc)`)
|
|
68
|
+
}
|
|
69
|
+
if (eslintRaw.includes('@nitra/eslint-config')) {
|
|
70
|
+
pass(`${eslintPath}: імпорт @nitra/eslint-config`)
|
|
71
|
+
} else {
|
|
72
|
+
fail(`${eslintPath}: імпортуй getConfig з @nitra/eslint-config`)
|
|
73
|
+
}
|
|
74
|
+
if (eslintRaw.includes('**/auto-imports.d.ts')) {
|
|
75
|
+
pass(`${eslintPath}: ignores містить **/auto-imports.d.ts`)
|
|
76
|
+
} else {
|
|
77
|
+
fail(`${eslintPath}: додай у ignores запис **/auto-imports.d.ts (js-lint.mdc)`)
|
|
78
|
+
}
|
|
29
79
|
}
|
|
30
80
|
|
|
31
81
|
if (existsSync('package.json')) {
|
|
@@ -34,38 +84,25 @@ export async function check() {
|
|
|
34
84
|
if (pkg.scripts?.['lint-js']) {
|
|
35
85
|
pass('package.json містить скрипт lint-js')
|
|
36
86
|
const lintJs = String(pkg.scripts['lint-js'])
|
|
37
|
-
if (lintJs
|
|
38
|
-
pass(
|
|
39
|
-
} else {
|
|
40
|
-
fail('lint-js має викликати jscpd — додай "&& bunx jscpd ." у кінець скрипта')
|
|
41
|
-
}
|
|
42
|
-
if (lintJs.includes('bunx eslint')) {
|
|
43
|
-
pass('lint-js викликає bunx eslint')
|
|
44
|
-
} else {
|
|
45
|
-
fail('lint-js має містити bunx eslint (n-js-lint.mdc)')
|
|
46
|
-
}
|
|
47
|
-
if (lintJs.includes('bunx jscpd')) {
|
|
48
|
-
pass('lint-js викликає bunx jscpd')
|
|
49
|
-
} else {
|
|
50
|
-
fail('lint-js має містити bunx jscpd (n-js-lint.mdc)')
|
|
51
|
-
}
|
|
52
|
-
if (lintJs.includes('oxlint')) {
|
|
53
|
-
pass('lint-js містить oxlint')
|
|
87
|
+
if (isCanonicalLintJs(lintJs)) {
|
|
88
|
+
pass(`lint-js збігається з каноном: ${CANONICAL_LINT_JS}`)
|
|
54
89
|
} else {
|
|
55
|
-
fail(
|
|
90
|
+
fail(
|
|
91
|
+
`lint-js має бути рівно: "${CANONICAL_LINT_JS}" (oxlint без bunx; див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
|
|
92
|
+
)
|
|
56
93
|
}
|
|
57
94
|
} else {
|
|
58
|
-
fail(
|
|
95
|
+
fail(`package.json не містить скрипт "lint-js" — додай: ${JSON.stringify(CANONICAL_LINT_JS)}`)
|
|
59
96
|
}
|
|
60
97
|
|
|
61
98
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
62
99
|
if (allDeps.prettier) {
|
|
63
|
-
fail('package.json: видали залежність prettier (oxfmt замість prettier,
|
|
100
|
+
fail('package.json: видали залежність prettier (oxfmt замість prettier, js-lint.mdc)')
|
|
64
101
|
} else {
|
|
65
102
|
pass('package.json не містить prettier')
|
|
66
103
|
}
|
|
67
104
|
if (allDeps['@nitra/prettier-config']) {
|
|
68
|
-
fail('package.json: видали @nitra/prettier-config (
|
|
105
|
+
fail('package.json: видали @nitra/prettier-config (js-lint.mdc)')
|
|
69
106
|
} else {
|
|
70
107
|
pass('package.json не містить @nitra/prettier-config')
|
|
71
108
|
}
|
|
@@ -78,7 +115,7 @@ export async function check() {
|
|
|
78
115
|
|
|
79
116
|
const nodeEngine = pkg.engines?.node
|
|
80
117
|
if (nodeEngine) {
|
|
81
|
-
const match = nodeEngine.match(/(\d+)/)
|
|
118
|
+
const match = nodeEngine.match(/(\d+)/u)
|
|
82
119
|
if (match && Number(match[1]) >= 24) {
|
|
83
120
|
pass(`engines.node: "${nodeEngine}"`)
|
|
84
121
|
} else {
|
|
@@ -89,26 +126,68 @@ export async function check() {
|
|
|
89
126
|
}
|
|
90
127
|
}
|
|
91
128
|
|
|
129
|
+
if (existsSync('.vscode/extensions.json')) {
|
|
130
|
+
let ext
|
|
131
|
+
try {
|
|
132
|
+
ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
|
|
133
|
+
} catch {
|
|
134
|
+
fail('.vscode/extensions.json не є валідним JSON')
|
|
135
|
+
ext = null
|
|
136
|
+
}
|
|
137
|
+
if (ext) {
|
|
138
|
+
const rec = ext.recommendations
|
|
139
|
+
if (Array.isArray(rec)) {
|
|
140
|
+
const missing = REQUIRED_VSCODE_EXTENSIONS.filter(id => !rec.includes(id))
|
|
141
|
+
if (missing.length > 0) {
|
|
142
|
+
fail(`.vscode/extensions.json: додай у recommendations: ${missing.join(', ')} (мінімум для js-lint.mdc)`)
|
|
143
|
+
} else {
|
|
144
|
+
pass('.vscode/extensions.json: є рекомендації oxlint, eslint і GitHub Actions')
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
fail('.vscode/extensions.json: поле recommendations має бути масивом')
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
fail('.vscode/extensions.json не існує — додай recommendations з js-lint.mdc (див. check-js-lint.mjs)')
|
|
152
|
+
}
|
|
153
|
+
|
|
92
154
|
if (existsSync('.github/workflows/lint-js.yml')) {
|
|
93
155
|
const content = await readFile('.github/workflows/lint-js.yml', 'utf8')
|
|
94
156
|
pass('lint-js.yml існує')
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
157
|
+
const checks = [
|
|
158
|
+
['actions/checkout@v6', 'lint-js.yml: потрібен крок actions/checkout@v6 (ga.mdc)'],
|
|
159
|
+
['persist-credentials: false', 'lint-js.yml: checkout з persist-credentials: false'],
|
|
160
|
+
['./.github/actions/setup-bun-deps', 'lint-js.yml: потрібен uses: ./.github/actions/setup-bun-deps'],
|
|
161
|
+
['bunx oxlint', 'lint-js.yml: у run має бути bunx oxlint'],
|
|
162
|
+
['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
|
|
163
|
+
['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
|
|
164
|
+
]
|
|
165
|
+
for (const [needle, errMsg] of checks) {
|
|
166
|
+
if (content.includes(needle)) {
|
|
167
|
+
pass(`lint-js.yml містить: ${needle}`)
|
|
168
|
+
} else {
|
|
169
|
+
fail(errMsg)
|
|
170
|
+
}
|
|
99
171
|
}
|
|
100
|
-
if (content.includes('
|
|
101
|
-
|
|
102
|
-
} else {
|
|
103
|
-
fail('lint-js.yml не містить eslint')
|
|
172
|
+
if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
|
|
173
|
+
fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
|
|
104
174
|
}
|
|
105
|
-
if (content.includes('
|
|
106
|
-
|
|
107
|
-
} else {
|
|
108
|
-
fail('lint-js.yml не містить jscpd — додай крок bunx jscpd .')
|
|
175
|
+
if (content.includes('eslint --fix')) {
|
|
176
|
+
fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
|
|
109
177
|
}
|
|
110
178
|
} else {
|
|
111
|
-
fail('.github/workflows/lint-js.yml не існує — створи його')
|
|
179
|
+
fail('.github/workflows/lint-js.yml не існує — створи його (див. check-js-lint.mjs / js-lint.mdc)')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (existsSync('.github/workflows/lint.yml')) {
|
|
183
|
+
const lintYml = await readFile('.github/workflows/lint.yml', 'utf8')
|
|
184
|
+
const looksLikeJsLint =
|
|
185
|
+
/\bbunx\s+oxlint\b/u.test(lintYml) && /\bbunx\s+eslint\b/u.test(lintYml) && /\bjscpd\b/u.test(lintYml)
|
|
186
|
+
if (looksLikeJsLint) {
|
|
187
|
+
fail('.github/workflows/lint.yml дублює кроки lint-js.yml — залиш один workflow на лінт JS (js-lint.mdc)')
|
|
188
|
+
} else {
|
|
189
|
+
pass('.github/workflows/lint.yml не дублює oxlint/eslint/jscpd з lint-js.yml')
|
|
190
|
+
}
|
|
112
191
|
}
|
|
113
192
|
|
|
114
193
|
if (existsSync('.jscpd.json')) {
|
|
@@ -131,13 +210,25 @@ export async function check() {
|
|
|
131
210
|
} else {
|
|
132
211
|
fail('.jscpd.json має містити "exitCode": 1 (інакше CI не впаде на клонах)')
|
|
133
212
|
}
|
|
213
|
+
const reporters = jscpdCfg.reporters
|
|
214
|
+
if (Array.isArray(reporters) && reporters.includes('console')) {
|
|
215
|
+
pass('.jscpd.json: reporters містить console')
|
|
216
|
+
} else {
|
|
217
|
+
fail('.jscpd.json має містити "reporters": ["console"] (або масив із "console")')
|
|
218
|
+
}
|
|
219
|
+
const minLines = jscpdCfg.minLines
|
|
220
|
+
if (typeof minLines === 'number' && minLines >= 25) {
|
|
221
|
+
pass(`.jscpd.json: minLines ${minLines} (>=25)`)
|
|
222
|
+
} else {
|
|
223
|
+
fail('.jscpd.json має містити "minLines" як число >= 25')
|
|
224
|
+
}
|
|
134
225
|
}
|
|
135
226
|
} else {
|
|
136
|
-
fail('.jscpd.json не існує — створи з
|
|
227
|
+
fail('.jscpd.json не існує — створи з полями згідно check js-lint')
|
|
137
228
|
}
|
|
138
229
|
|
|
139
230
|
for (const dup of ['.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml']) {
|
|
140
|
-
if (existsSync(dup)) fail(`Знайдено застарілий конфіг ESLint: ${dup} — видали, використовуй
|
|
231
|
+
if (existsSync(dup)) fail(`Знайдено застарілий конфіг ESLint: ${dup} — видали, використовуй flat config`)
|
|
141
232
|
}
|
|
142
233
|
|
|
143
234
|
return exitCode
|
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,78 @@ function extractApiVersionAndKind(doc) {
|
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
|
|
210
|
+
* @param {unknown} manifest корінь YAML-документа як об'єкт JavaScript
|
|
211
|
+
* @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
|
|
212
|
+
*/
|
|
213
|
+
export function deploymentResourcesViolation(manifest) {
|
|
214
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
215
|
+
return null
|
|
216
|
+
const rec = /** @type {Record<string, unknown>} */ (manifest)
|
|
217
|
+
if (rec.kind !== 'Deployment') return null
|
|
218
|
+
const spec = rec.spec
|
|
219
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
|
|
220
|
+
const template = /** @type {Record<string, unknown>} */ (spec).template
|
|
221
|
+
if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template))
|
|
222
|
+
return null
|
|
223
|
+
const podSpec = /** @type {Record<string, unknown>} */ (template).spec
|
|
224
|
+
if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
|
|
225
|
+
const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
|
|
226
|
+
if (!Array.isArray(containers)) return null
|
|
227
|
+
|
|
228
|
+
for (const [i, c] of containers.entries()) {
|
|
229
|
+
const label =
|
|
230
|
+
typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
|
|
231
|
+
? c.name
|
|
232
|
+
: `#${i + 1}`
|
|
233
|
+
if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
|
|
234
|
+
const cont = /** @type {Record<string, unknown>} */ (c)
|
|
235
|
+
if (!('resources' in cont)) {
|
|
236
|
+
return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
|
|
237
|
+
}
|
|
238
|
+
const r = cont.resources
|
|
239
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r)) {
|
|
240
|
+
return `контейнер "${label}": resources має бути об'єктом (наприклад порожній об'єкт у YAML: resources: {})`
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return null
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Парсить усі YAML-документи з тіла файлу й реєструє порушення **`Deployment.resources`**.
|
|
250
|
+
* @param {string} rel відносний шлях (для повідомлень)
|
|
251
|
+
* @param {string} body вміст після modeline
|
|
252
|
+
* @param {(msg: string) => void} fail реєстрація помилки
|
|
253
|
+
*/
|
|
254
|
+
function validateDeploymentResourcesInK8sYaml(rel, body, fail) {
|
|
255
|
+
/** @type {import('yaml').Document[]} */
|
|
256
|
+
let docs
|
|
257
|
+
try {
|
|
258
|
+
docs = parseAllDocuments(body)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
261
|
+
fail(
|
|
262
|
+
`${rel}: не вдалося розібрати YAML для перевірки Deployment.spec.template.spec.containers[].resources (${msg})`
|
|
263
|
+
)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const [di, doc] of docs.entries()) {
|
|
268
|
+
if (doc.errors.length > 0) {
|
|
269
|
+
fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
|
|
270
|
+
} else {
|
|
271
|
+
const obj = doc.toJSON()
|
|
272
|
+
const v = deploymentResourcesViolation(obj)
|
|
273
|
+
if (v !== null) {
|
|
274
|
+
fail(`${rel}: Deployment (документ ${di + 1}): ${v}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
202
280
|
/**
|
|
203
281
|
* Kind для імен файлів yannh/datree: лише літери та цифри, нижній регістр (Service → service, HTTPRoute → httproute).
|
|
204
282
|
* @param {string} kind значення поля kind
|
|
@@ -318,31 +396,31 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
|
|
|
318
396
|
return
|
|
319
397
|
}
|
|
320
398
|
|
|
399
|
+
const body = yamlBodyAfterModeline(lines)
|
|
400
|
+
|
|
321
401
|
if (schemaUrl.startsWith('file:')) {
|
|
322
402
|
pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
|
|
323
|
-
|
|
324
|
-
|
|
403
|
+
} else if (/^https:/iu.test(schemaUrl)) {
|
|
404
|
+
const doc = firstYamlDocument(body)
|
|
405
|
+
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
325
406
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const body = yamlBodyAfterModeline(lines)
|
|
332
|
-
const doc = firstYamlDocument(body)
|
|
333
|
-
const { expected, reason } = expectedSchemaUrl(abs, doc)
|
|
407
|
+
if (expected === null) {
|
|
408
|
+
fail(`${rel}: ${reason}`)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
334
411
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
412
|
+
if (schemaUrl !== expected) {
|
|
413
|
+
fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
339
416
|
|
|
340
|
-
|
|
341
|
-
|
|
417
|
+
pass(`${rel}: $schema узгоджено (${reason})`)
|
|
418
|
+
} else {
|
|
419
|
+
fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
|
|
342
420
|
return
|
|
343
421
|
}
|
|
344
422
|
|
|
345
|
-
|
|
423
|
+
validateDeploymentResourcesInK8sYaml(rel, body, fail)
|
|
346
424
|
}
|
|
347
425
|
|
|
348
426
|
/**
|
|
@@ -1,13 +1,263 @@
|
|
|
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
|
+
// cspell:ignore fastcgi uwsgi
|
|
147
|
+
const proxyLike =
|
|
148
|
+
/\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
|
|
149
|
+
if (proxyLike.test(content)) {
|
|
150
|
+
return 'знайдено proxy, gRPC або інший *_pass до бекенду — прибери з шаблону, логіку винеси в HTTPRoute (k8s) (див. nginx-default-tpl.mdc)'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Чи HTTPRoute відповідає патерну Exact→RequestRedirect(301, https) + PathPrefix→backendRefs:8080.
|
|
158
|
+
* @param {unknown} manifest корінь YAML-документа
|
|
159
|
+
* @returns {boolean} true, якщо структура збігається з прикладом у nginx-default-tpl.mdc
|
|
160
|
+
*/
|
|
161
|
+
export function httpRouteMatchesNginxDefaultTpl(manifest) {
|
|
162
|
+
if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
|
|
163
|
+
return false
|
|
164
|
+
const m = /** @type {Record<string, unknown>} */ (manifest)
|
|
165
|
+
if (m.kind !== 'HTTPRoute') return false
|
|
166
|
+
const spec = m.spec
|
|
167
|
+
if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return false
|
|
168
|
+
const rules = /** @type {Record<string, unknown>} */ (spec).rules
|
|
169
|
+
if (!Array.isArray(rules) || rules.length < 2) return false
|
|
170
|
+
|
|
171
|
+
const [first, second] = rules
|
|
172
|
+
if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) return false
|
|
173
|
+
if (second === null || second === undefined || typeof second !== 'object' || Array.isArray(second)) return false
|
|
174
|
+
|
|
175
|
+
const r0 = /** @type {Record<string, unknown>} */ (first)
|
|
176
|
+
const r1 = /** @type {Record<string, unknown>} */ (second)
|
|
177
|
+
|
|
178
|
+
const matches0 = r0.matches
|
|
179
|
+
const filters0 = r0.filters
|
|
180
|
+
const matches1 = r1.matches
|
|
181
|
+
const backends1 = r1.backendRefs
|
|
182
|
+
|
|
183
|
+
const hasExact =
|
|
184
|
+
Array.isArray(matches0) &&
|
|
185
|
+
matches0.some(x => {
|
|
186
|
+
if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
|
|
187
|
+
return /** @type {Record<string, unknown>} */ (x).path?.type === 'Exact'
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
const hasRedirect =
|
|
191
|
+
Array.isArray(filters0) &&
|
|
192
|
+
filters0.some(f => {
|
|
193
|
+
if (f === null || f === undefined || typeof f !== 'object' || Array.isArray(f)) return false
|
|
194
|
+
const fr = /** @type {Record<string, unknown>} */ (f)
|
|
195
|
+
if (fr.type !== 'RequestRedirect') return false
|
|
196
|
+
const rr = fr.requestRedirect
|
|
197
|
+
if (rr === null || rr === undefined || typeof rr !== 'object' || Array.isArray(rr)) return false
|
|
198
|
+
const red = /** @type {Record<string, unknown>} */ (rr)
|
|
199
|
+
const code = red.statusCode
|
|
200
|
+
const okCode = code === 301 || code === '301'
|
|
201
|
+
return red.scheme === 'https' && red.path?.type === 'ReplaceFullPath' && okCode
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const hasPrefix =
|
|
205
|
+
Array.isArray(matches1) &&
|
|
206
|
+
matches1.some(x => {
|
|
207
|
+
if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
|
|
208
|
+
return /** @type {Record<string, unknown>} */ (x).path?.type === 'PathPrefix'
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
const has8080 =
|
|
212
|
+
Array.isArray(backends1) &&
|
|
213
|
+
backends1.some(b => {
|
|
214
|
+
if (b === null || b === undefined || typeof b !== 'object' || Array.isArray(b)) return false
|
|
215
|
+
const p = /** @type {Record<string, unknown>} */ (b).port
|
|
216
|
+
return p === 8080 || p === '8080'
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return hasExact && hasRedirect && hasPrefix && has8080
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Кожен ключ з ini має входити в шаблон як `$KEY` (envsubst).
|
|
224
|
+
* @param {string[]} keys імена змінних
|
|
225
|
+
* @param {string} template вміст default.conf.template
|
|
226
|
+
* @returns {string | null} повідомлення або null
|
|
227
|
+
*/
|
|
228
|
+
export function iniKeysMissingInTemplate(keys, template) {
|
|
229
|
+
for (const k of keys) {
|
|
230
|
+
if (!template.includes(`$${k}`)) {
|
|
231
|
+
return `змінна "${k}" з *.ini не використовується в шаблоні — вилучи її з ini або додай у шаблон $${k} (див. nginx-default-tpl.mdc)`
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Чи Dockerfile містить RUN із find/gzip для статики під `/usr/share/nginx/html`.
|
|
239
|
+
* @param {string} dockerfileContent повний текст Dockerfile
|
|
240
|
+
* @returns {boolean} true, якщо знайдено типовий крок стиснення
|
|
241
|
+
*/
|
|
242
|
+
function dockerfileHasGzipStaticPipeline(dockerfileContent) {
|
|
243
|
+
const c = dockerfileContent
|
|
244
|
+
return (
|
|
245
|
+
/\bfind\b/u.test(c) &&
|
|
246
|
+
c.includes('/usr/share/nginx/html') &&
|
|
247
|
+
/\bgzip\b/u.test(c) &&
|
|
248
|
+
c.includes('-k') &&
|
|
249
|
+
/\*\.(?:js|css)/u.test(c)
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Чи Dockerfile містить envsubst для **default.conf.template**.
|
|
255
|
+
* @param {string} dockerfileContent повний текст Dockerfile
|
|
256
|
+
* @returns {boolean} true, якщо є envsubst і посилання на шаблон
|
|
257
|
+
*/
|
|
258
|
+
function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
|
|
259
|
+
return dockerfileContent.includes('envsubst') && dockerfileContent.includes('default.conf.template')
|
|
260
|
+
}
|
|
11
261
|
|
|
12
262
|
/**
|
|
13
263
|
* Перевіряє відповідність проєкту правилам nginx-default-tpl.mdc
|
|
@@ -20,56 +270,117 @@ export async function check() {
|
|
|
20
270
|
exitCode = 1
|
|
21
271
|
}
|
|
22
272
|
|
|
23
|
-
|
|
24
|
-
|
|
273
|
+
const root = process.cwd()
|
|
274
|
+
|
|
275
|
+
const { renamed: tplRenamed, overwritten: tplOverwritten } = await migrateDefaultTplConfFiles(root)
|
|
276
|
+
for (const rel of tplRenamed) {
|
|
277
|
+
pass(`Перейменовано default.tpl.conf → default.conf.template: ${rel}`)
|
|
278
|
+
}
|
|
279
|
+
for (const rel of tplOverwritten) {
|
|
280
|
+
pass(`Перезаписано default.conf.template змістом default.tpl.conf: ${rel}`)
|
|
25
281
|
}
|
|
26
282
|
|
|
27
|
-
const
|
|
28
|
-
|
|
283
|
+
const templates = await findDefaultConfTemplatePaths(root)
|
|
284
|
+
|
|
285
|
+
if (templates.length === 0) {
|
|
286
|
+
pass('Немає default.conf.template — перевірку nginx-default-tpl пропущено')
|
|
287
|
+
return 0
|
|
288
|
+
}
|
|
29
289
|
|
|
30
|
-
|
|
31
|
-
pass(`${found} існує`)
|
|
32
|
-
const content = await readFile(found, 'utf8')
|
|
290
|
+
pass(`Знайдено default.conf.template: ${templates.length}`)
|
|
33
291
|
|
|
34
|
-
|
|
35
|
-
|
|
292
|
+
for (const abs of templates) {
|
|
293
|
+
const rel = relative(root, abs) || abs
|
|
294
|
+
const content = await readFile(abs, 'utf8')
|
|
295
|
+
const v = nginxTemplateViolations(content)
|
|
296
|
+
if (v) {
|
|
297
|
+
fail(`${rel}: ${v}`)
|
|
36
298
|
} else {
|
|
37
|
-
|
|
299
|
+
pass(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
|
|
38
300
|
}
|
|
39
301
|
|
|
40
|
-
|
|
41
|
-
|
|
302
|
+
const dir = dirname(abs)
|
|
303
|
+
let iniNames = []
|
|
304
|
+
try {
|
|
305
|
+
const dirEntries = await readdir(dir)
|
|
306
|
+
iniNames = dirEntries.filter(n => n.endsWith('.ini'))
|
|
307
|
+
} catch {
|
|
308
|
+
iniNames = []
|
|
309
|
+
}
|
|
310
|
+
if (iniNames.length === 0) {
|
|
311
|
+
fail(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
|
|
42
312
|
} else {
|
|
43
|
-
|
|
313
|
+
pass(`${rel}: поруч є *.ini (${iniNames.length})`)
|
|
44
314
|
}
|
|
45
315
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
316
|
+
for (const iniName of iniNames) {
|
|
317
|
+
const iniPath = `${dir}/${iniName}`
|
|
318
|
+
const iniRel = relative(root, iniPath) || iniPath
|
|
319
|
+
let iniRaw
|
|
320
|
+
try {
|
|
321
|
+
iniRaw = await readFile(iniPath, 'utf8')
|
|
322
|
+
} catch (error) {
|
|
323
|
+
fail(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
|
|
324
|
+
iniRaw = null
|
|
325
|
+
}
|
|
326
|
+
if (iniRaw !== null) {
|
|
327
|
+
const keys = parseIniVariableNames(iniRaw)
|
|
328
|
+
const miss = iniKeysMissingInTemplate(keys, content)
|
|
329
|
+
if (miss) {
|
|
330
|
+
fail(`${iniRel}: ${miss}`)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
50
333
|
}
|
|
334
|
+
}
|
|
51
335
|
|
|
52
|
-
|
|
53
|
-
|
|
336
|
+
const dockerPaths = await findDockerfilePaths(root)
|
|
337
|
+
if (dockerPaths.length === 0) {
|
|
338
|
+
fail(
|
|
339
|
+
'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
|
|
340
|
+
)
|
|
341
|
+
} else {
|
|
342
|
+
const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
|
|
343
|
+
const gzipOk = bodies.some(body => dockerfileHasGzipStaticPipeline(body))
|
|
344
|
+
const envOk = bodies.some(body => dockerfileHasEnvsSubstTemplate(body))
|
|
345
|
+
if (gzipOk) {
|
|
346
|
+
pass('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
|
|
347
|
+
} else {
|
|
348
|
+
fail('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
|
|
349
|
+
}
|
|
350
|
+
if (envOk) {
|
|
351
|
+
pass('Dockerfile: знайдено envsubst для default.conf.template')
|
|
352
|
+
} else {
|
|
353
|
+
fail('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
|
|
54
354
|
}
|
|
55
355
|
}
|
|
56
356
|
|
|
57
357
|
if (existsSync('.vscode/extensions.json')) {
|
|
58
|
-
const
|
|
358
|
+
const extRaw = await readFile('.vscode/extensions.json', 'utf8')
|
|
359
|
+
const ext = JSON.parse(extRaw)
|
|
59
360
|
if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
|
|
60
361
|
pass('extensions.json містить ahmadalli.vscode-nginx-conf')
|
|
61
362
|
} else {
|
|
62
363
|
fail('extensions.json не містить ahmadalli.vscode-nginx-conf')
|
|
63
364
|
}
|
|
365
|
+
} else {
|
|
366
|
+
fail('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
|
|
64
367
|
}
|
|
65
368
|
|
|
66
369
|
if (existsSync('.vscode/settings.json')) {
|
|
67
|
-
const
|
|
370
|
+
const settingsRaw = await readFile('.vscode/settings.json', 'utf8')
|
|
371
|
+
const s = JSON.parse(settingsRaw)
|
|
372
|
+
if (s['editor.formatOnSave'] === true) {
|
|
373
|
+
pass('settings.json: editor.formatOnSave увімкнено')
|
|
374
|
+
} else {
|
|
375
|
+
fail('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
|
|
376
|
+
}
|
|
68
377
|
if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
|
|
69
|
-
pass('settings.json: nginx
|
|
378
|
+
pass('settings.json: [nginx] defaultFormatter налаштовано')
|
|
70
379
|
} else {
|
|
71
|
-
fail('settings.json: [nginx]
|
|
380
|
+
fail('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
|
|
72
381
|
}
|
|
382
|
+
} else {
|
|
383
|
+
fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
|
|
73
384
|
}
|
|
74
385
|
|
|
75
386
|
return exitCode
|