@nitra/cursor 1.8.199 → 1.8.201
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/CHANGELOG.md +22 -0
- package/mdc/abie.mdc +41 -1
- package/mdc/k8s.mdc +46 -0
- package/package.json +2 -1
- package/policy/ga/clean-ga-workflows.rego +133 -0
- package/scripts/check-abie.mjs +142 -1
- package/scripts/check-hasura.mjs +20 -9
- package/scripts/check-k8s.mjs +200 -0
- package/scripts/lint-ga.mjs +71 -2
- package/scripts/lint-rego.mjs +70 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.8.201] - 2026-05-07
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `check-hasura.mjs`: `INTERNAL_HASURA_URL_RE` тепер приймає **обидва** кластерні DNS-суфікси у `HASURA_GRAPHQL_ENDPOINT` — `<cluster>.internal` (GKE/GCP, наприклад `abie-dev` / `abie-ua`) **і** `cluster.local` (стандартний k8s / Yandex Cloud). Раніше regex вимагав літеральний `.internal` у кінці, тож URL виду `http://apruv-h-hl.ru-apruv.svc.cluster.local:8080` (типовий для YC-кластера ru) помилково відхилявся. `parseInternalHasuraEndpoint` для YC повертає `cluster: 'cluster.local'` як повний суфікс, для GKE — ім'я кластера без `.internal` (зворотньо сумісно з попередньою поведінкою). Текст помилки в `checkEnvFile` оновлено — згадує обидва допустимі формати.
|
|
12
|
+
- `abie.mdc` (v1.17 → v1.19): нова секція «Внутрішньокластерні URL у env-файлах (dev / ua / ru)». Правило стосується **будь-якого** internal URL у env-файлах abie-проєкту — не лише `HASURA_GRAPHQL_ENDPOINT`, а й KVCMS, `auth-run-hl`, `file-link-hl` тощо. Таблиця `dev.env` / `ua.env` / `ru.env` → namespace-префікс + DNS-суфікс кластера (dev → `abie-dev.internal` + `dev-…`, ua → `abie-ua.internal` + `ua-…`, ru → `cluster.local` + `ru-…`); приклади з двома сервісами в одному файлі (Hasura + KVCMS). Загальне правило про **внутрішній** URL замість публічного домену для `HASURA_GRAPHQL_ENDPOINT` лишається у `hasura.mdc` (для nitra та abie).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `check-abie.mjs`: новий валідатор `validateAbieEnvInternalUrls` (`String.prototype.matchAll` за `ABIE_INTERNAL_URL_GLOBAL_RE`) і helper `abieEnvNameFromBasename`. У функції `check()` додано крок `ensureAbieEnvFilesMatchClusterDns`, що сканує всі `*.env`-файли (basename `dev.env` / `ua.env` / `ru.env` опційно з провідною крапкою; `.env` без імені пропускається — як у `check-hasura.mjs`) і для **кожного** знайденого URL виду `http://<svc>.<ns>.svc.<dns>` перевіряє відповідність DNS-суфікса й namespace-префікса середовищу env-файла. Помилки додаються через `fail`, без зупинки на першому файлі — звіт показує всі порушення в усіх env-файлах одразу.
|
|
17
|
+
- `tests/check-hasura.test.mjs`: тести `parseInternalHasuraEndpoint` для GKE-style `abie-dev.internal` / `abie-ua.internal` та YC-style `cluster.local`; негативний кейс на сторонній суфікс (`svc.example.com`); інтеграційний тест `check()` для `hasura/.ru.env` з `cluster.local`.
|
|
18
|
+
- `tests/check-abie.test.mjs`: 7 unit-тестів на `abieEnvNameFromBasename` і `validateAbieEnvInternalUrls` (узгоджений dev/ua/ru, URL без порту, dev URL у ua-файлі, internal-суфікс у ru-файлі, ігнорування зовнішніх `https://` / `localhost`, кілька URL з різними порушеннями) і 4 інтеграційні (`.dev.env`+`.ua.env`+`.ru.env` узгоджені — 0; ua з dev URL у KVCMS — 1; ru з `.internal` замість `cluster.local` — 1; `.env` без імені пропускається).
|
|
19
|
+
|
|
20
|
+
## [1.8.200] - 2026-05-07
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `policy/ga/clean-ga-workflows.rego` + новий PoC-крок у `scripts/lint-ga.mjs`: запускає `conftest test` на `.github/workflows/clean-ga-workflows.yml` проти Rego-полісі (структура `name` / `on` / `concurrency` / `jobs.cleanup_old_workflows.steps[0]`). Якщо `conftest` не в PATH — `ℹ` skip без помилки (паралельні JS-перевірки в `check-ga.mjs` залишаються джерелом істини). Додав `policy` у `files` пакету.
|
|
25
|
+
- `check-k8s.mjs`: структурний сорт `patches[]` у `kustomization.yaml` за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`); поля `target.group` / `target.version` у tuple не входять (діє правило «patches[].target: лише kind і name»). Додатково: вміст inline `patches[i].patch` (literal block scalar — масив JSON6902) сортується за `path`, **але лише** коли всі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого) — інакше порядок не чіпається, бо `move` / `copy` / `test` / `remove` чи спільні шляхи семантично залежні (RFC 6902). Експортовані чисті валідатори: `kustomizationPatchesSortedViolation`, `kustomizationInlinePatchOpsSortedViolation`.
|
|
26
|
+
- `tests/check-k8s-schema.test.mjs`: тести на обидва нові валідатори (приклад із `k8s.mdc`: `ReferenceGrant atlas/apruv` → `apruv/atlas`; `add /spec/minReplicas` + `replace /spec/maxReplicas` → пересорт за `path`; пропуск для `test` / `move` / `copy` / `remove` і недизʼюнктних шляхів типу `/spec` vs `/spec/template`).
|
|
27
|
+
- `mdc/k8s.mdc`: розділ «Структурний сорт `patches[]` і inline JSON6902» з обома прикладами «❌/✅».
|
|
28
|
+
|
|
7
29
|
## [1.8.199] - 2026-05-07
|
|
8
30
|
|
|
9
31
|
### Added
|
package/mdc/abie.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Правила для проєктів AbInBev Efes
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.19'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`file-link-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона тримати артефакти **Firebase Hosting** у **підкаталогах першого рівня** (безпосередні діти кореня репозиторію; у самому корені ці імена не вимагаються до видалення).
|
|
@@ -332,6 +332,46 @@ spec:
|
|
|
332
332
|
preem: 'true' # буде замінено через kustomize
|
|
333
333
|
```
|
|
334
334
|
|
|
335
|
+
## Внутрішньокластерні URL у env-файлах (dev / ua / ru)
|
|
336
|
+
|
|
337
|
+
Правило стосується **будь-якого** внутрішньокластерного URL у env-файлах abie-проєкту, а не лише `HASURA_GRAPHQL_ENDPOINT`. Це може бути URL до Hasura, KVCMS, `auth-run-hl`, `file-link-hl` чи будь-якого іншого Service у кластері — у всіх випадках DNS-суфікс і namespace-префікс мають відповідати **середовищу** з імені env-файлу.
|
|
338
|
+
|
|
339
|
+
abie-проєкти живуть у **трьох різних кластерах** (dev / ua у GKE, ru у Yandex Cloud), тож DNS-суфікс і namespace у URL відрізняються між `*.env`-файлами:
|
|
340
|
+
|
|
341
|
+
| env-файл (basename) | namespace-префікс у URL | DNS-суфікс кластера | примітка |
|
|
342
|
+
| --- | --- | --- | --- |
|
|
343
|
+
| `dev.env`, `.dev.env` | `dev-…` | `abie-dev.internal` | GKE-кластер dev |
|
|
344
|
+
| `ua.env`, `.ua.env` | `ua-…` | `abie-ua.internal` | GKE-кластер ua |
|
|
345
|
+
| `ru.env`, `.ru.env` | `ru-…` | `cluster.local` | YC-кластер ru, стандартний k8s DNS |
|
|
346
|
+
|
|
347
|
+
Канонічна форма URL — `http://<service>.<namespace>.svc.<cluster-dns-suffix>:<port>`. Для GKE (dev / ua) суфікс — `<cluster>.internal`; для YC (ru) — фіксований `cluster.local` (у YC у DNS сервісу немає окремого імені кластера).
|
|
348
|
+
|
|
349
|
+
Приклади для одного env-файлу з двома сервісами (Hasura + KVCMS):
|
|
350
|
+
|
|
351
|
+
```env title="hasura/.dev.env"
|
|
352
|
+
HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.dev-apruv.svc.abie-dev.internal:8080
|
|
353
|
+
KVCMS_URL=http://kvcms-hl.dev-apruv.svc.abie-dev.internal:8080
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
```env title="hasura/.ua.env"
|
|
357
|
+
HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.ua-apruv.svc.abie-ua.internal:8080
|
|
358
|
+
KVCMS_URL=http://kvcms-hl.ua-apruv.svc.abie-ua.internal:8080
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
```env title="hasura/.ru.env"
|
|
362
|
+
HASURA_GRAPHQL_ENDPOINT=http://apruv-h-hl.ru-apruv.svc.cluster.local:8080
|
|
363
|
+
KVCMS_URL=http://kvcms-hl.ru-apruv.svc.cluster.local:8080
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
`<namespace>` (наприклад `dev-apruv` / `ua-apruv` / `ru-apruv`) — `metadata.name` цільового namespace після kustomize-overlay для відповідного середовища; `<service>` — `metadata.name` headless Service (`-hl`) того сервісу, до якого йде URL.
|
|
367
|
+
|
|
368
|
+
**Перевірка `check-abie.mjs`** сканує всі `*.env` файли, basename яких збігається з `dev.env` / `ua.env` / `ru.env` (з провідною крапкою чи без), знаходить **усі** internal URL (`http://<svc>.<ns>.svc.<dns>` — як для Hasura-ендпоінта, так і для KVCMS чи будь-якого іншого) і вимагає, щоб для кожного:
|
|
369
|
+
|
|
370
|
+
- DNS-суфікс відповідав env: `abie-dev.internal` / `abie-ua.internal` / `cluster.local`;
|
|
371
|
+
- namespace починався з `dev-` / `ua-` / `ru-` відповідно.
|
|
372
|
+
|
|
373
|
+
Загальне правило про **внутрішній** URL (не публічний домен) для `HASURA_GRAPHQL_ENDPOINT` лишається у **`hasura.mdc`** (для nitra і abie) — `check-hasura.mjs` приймає обидва кластерні DNS-формати (`<cluster>.internal` і `cluster.local`).
|
|
374
|
+
|
|
335
375
|
## Firebase Hosting
|
|
336
376
|
|
|
337
377
|
У **кожному** підкаталозі, що лежить **безпосередньо** в корені репозиторію, не тримати конфіг і кеш **Firebase Hosting**: у таких каталогах не повинно бути **`.firebaserc`**, **`firebase.json`** та каталогу **`.firebase/`** (у **самому** корені репозиторію ці імена перевіркою abie **не** розглядаються; `node_modules` / `.git` зі скану вилучаються).
|
package/mdc/k8s.mdc
CHANGED
|
@@ -531,6 +531,52 @@ patches:
|
|
|
531
531
|
|
|
532
532
|
**Виняток:** залишай `group` / `version`, лише якщо в дереві overlay реально співіснують ресурси з однаковими `kind`+`name`, але різними API-групами/версіями (наприклад, дві CRD з одним `kind`). У такому разі вкажи мінімальний набір полів, потрібний для дисамбігуації.
|
|
533
533
|
|
|
534
|
+
### Структурний сорт `patches[]` і inline JSON6902
|
|
535
|
+
|
|
536
|
+
`patches[]` у `kustomization.yaml` має бути відсортовано за tuple **`target.kind` → `target.name` → `target.namespace` → `path`** (`localeCompare('en', { sensitivity: 'base' })`). Це робить діфи передбачуваними і прибирає «гойдання» порядку при додаванні нових цілей. Поля `target.group` / `target.version` у tuple не входять — для них діє правило «patches[].target: лише kind і name».
|
|
537
|
+
|
|
538
|
+
```yaml
|
|
539
|
+
# ❌ atlas йде перед apruv
|
|
540
|
+
patches:
|
|
541
|
+
- target:
|
|
542
|
+
kind: ReferenceGrant
|
|
543
|
+
name: atlas-to-base
|
|
544
|
+
- target:
|
|
545
|
+
kind: ReferenceGrant
|
|
546
|
+
name: apruv-to-base
|
|
547
|
+
|
|
548
|
+
# ✅
|
|
549
|
+
patches:
|
|
550
|
+
- target:
|
|
551
|
+
kind: ReferenceGrant
|
|
552
|
+
name: apruv-to-base
|
|
553
|
+
- target:
|
|
554
|
+
kind: ReferenceGrant
|
|
555
|
+
name: atlas-to-base
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Усередині кожного inline `patches[i].patch` (literal block scalar — масив JSON6902-операцій) операції теж сортуються за **`path`**, **але лише** коли набір «безпечний»: усі ops — `add` / `replace` і всі `path` попарно дизʼюнктні (жоден не префікс іншого, наприклад `/spec` і `/spec/replicas`). Інакше порядок не чіпається — у `move` / `copy` / `test` / `remove` чи на спільних шляхах послідовність ops семантично значуща (RFC 6902), і пересорт ламає логіку.
|
|
559
|
+
|
|
560
|
+
```yaml
|
|
561
|
+
# ❌ minReplicas перед maxReplicas (за алфавітом max < min)
|
|
562
|
+
patch: |-
|
|
563
|
+
- op: add
|
|
564
|
+
path: /spec/minReplicas
|
|
565
|
+
value: 2
|
|
566
|
+
- op: replace
|
|
567
|
+
path: /spec/maxReplicas
|
|
568
|
+
value: 10
|
|
569
|
+
|
|
570
|
+
# ✅
|
|
571
|
+
patch: |-
|
|
572
|
+
- op: replace
|
|
573
|
+
path: /spec/maxReplicas
|
|
574
|
+
value: 10
|
|
575
|
+
- op: add
|
|
576
|
+
path: /spec/minReplicas
|
|
577
|
+
value: 2
|
|
578
|
+
```
|
|
579
|
+
|
|
534
580
|
## Перевірка
|
|
535
581
|
|
|
536
582
|
**`npx @nitra/cursor check k8s`** — програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nitra/cursor",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.201",
|
|
4
4
|
"description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"mdc",
|
|
28
28
|
"bin",
|
|
29
29
|
"github-actions",
|
|
30
|
+
"policy",
|
|
30
31
|
"schemas",
|
|
31
32
|
"scripts",
|
|
32
33
|
"skills",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# PoC-порт перевірки `validateCleanGaWorkflows` з `npm/scripts/check-ga.mjs`.
|
|
2
|
+
#
|
|
3
|
+
# Запуск (локально):
|
|
4
|
+
# conftest test .github/workflows/clean-ga-workflows.yml -p npm/policy/ga
|
|
5
|
+
#
|
|
6
|
+
# Conftest читає YAML і дає його в `input`. Кожне правило `deny contains msg if { … }`,
|
|
7
|
+
# що матчиться, друкується як порушення; пустий список — exit 0.
|
|
8
|
+
#
|
|
9
|
+
# Rego v1 синтаксис (OPA 1.x за замовчуванням; `import rego.v1` робить файл портованим
|
|
10
|
+
# і на старі OPA 0.x): `contains` для partial set rules, `if` перед тілом правила.
|
|
11
|
+
package main
|
|
12
|
+
|
|
13
|
+
import rego.v1
|
|
14
|
+
|
|
15
|
+
# GHA YAML quirk: ключ `on:` парситься як YAML 1.1 boolean `true`, після чого conftest
|
|
16
|
+
# серіалізує його в Rego-input як рядок `"true"`. Тому `input.on` / `input["on"]` /
|
|
17
|
+
# `input[true]` всі недоступні; реальний шлях — `input["true"]`. Виносимо в alias, щоб
|
|
18
|
+
# решта правил читалася як `gha_on.schedule` без бойлерплейту.
|
|
19
|
+
gha_on := input["true"]
|
|
20
|
+
|
|
21
|
+
# `${{ … }}` — це шаблонний синтаксис GitHub Actions, але `{{` у Rego починає
|
|
22
|
+
# string interpolation. Збираємо очікувані рядки з фрагментів, як це зроблено в
|
|
23
|
+
# check-ga.mjs, щоб і Rego-парсер, і людина-читач не плуталися.
|
|
24
|
+
expected_concurrency_group := concat("", ["$", "{{ github.ref }}-$", "{{ github.workflow }}"])
|
|
25
|
+
|
|
26
|
+
expected_github_token := concat("", ["$", "{{ github.token }}"])
|
|
27
|
+
|
|
28
|
+
expected_name := "Clean action for removing completed workflow runs"
|
|
29
|
+
|
|
30
|
+
expected_cron := "0 1 16 * *"
|
|
31
|
+
|
|
32
|
+
# --- name --------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
deny contains msg if {
|
|
35
|
+
input.name != expected_name
|
|
36
|
+
msg := sprintf("clean-ga-workflows.yml: name має бути %q (ga.mdc)", [expected_name])
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# --- on.schedule.cron --------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
deny contains msg if {
|
|
42
|
+
not has_expected_cron
|
|
43
|
+
msg := sprintf("clean-ga-workflows.yml: on.schedule має містити cron: '%s' (ga.mdc)", [expected_cron])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
has_expected_cron if {
|
|
47
|
+
gha_on.schedule[_].cron == expected_cron
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# --- on.workflow_dispatch ----------------------------------------------------
|
|
51
|
+
|
|
52
|
+
deny contains msg if {
|
|
53
|
+
not has_workflow_dispatch
|
|
54
|
+
msg := "clean-ga-workflows.yml: має бути workflow_dispatch: {} (ga.mdc)"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
has_workflow_dispatch if {
|
|
58
|
+
is_object(gha_on.workflow_dispatch)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# --- concurrency -------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
deny contains msg if {
|
|
64
|
+
not is_object(input.concurrency)
|
|
65
|
+
msg := sprintf(
|
|
66
|
+
"clean-ga-workflows.yml: відсутня секція concurrency — додай concurrency.group: %s і cancel-in-progress: true (ga.mdc)",
|
|
67
|
+
[expected_concurrency_group],
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
deny contains msg if {
|
|
72
|
+
is_object(input.concurrency)
|
|
73
|
+
input.concurrency.group != expected_concurrency_group
|
|
74
|
+
msg := sprintf("clean-ga-workflows.yml: concurrency.group має бути %s (ga.mdc)", [expected_concurrency_group])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
deny contains msg if {
|
|
78
|
+
is_object(input.concurrency)
|
|
79
|
+
input.concurrency["cancel-in-progress"] != true
|
|
80
|
+
msg := "clean-ga-workflows.yml: concurrency.cancel-in-progress має бути true (ga.mdc)"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# --- jobs.cleanup_old_workflows ---------------------------------------------
|
|
84
|
+
|
|
85
|
+
deny contains msg if {
|
|
86
|
+
not input.jobs.cleanup_old_workflows
|
|
87
|
+
msg := "clean-ga-workflows.yml: jobs.cleanup_old_workflows відсутній (ga.mdc)"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
deny contains msg if {
|
|
91
|
+
job := input.jobs.cleanup_old_workflows
|
|
92
|
+
job["runs-on"] != "ubuntu-latest"
|
|
93
|
+
msg := "clean-ga-workflows.yml: runs-on має бути ubuntu-latest (ga.mdc)"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
deny contains msg if {
|
|
97
|
+
perms := input.jobs.cleanup_old_workflows.permissions
|
|
98
|
+
not actions_write_contents_read(perms)
|
|
99
|
+
msg := "clean-ga-workflows.yml: permissions мають бути actions: write, contents: read (ga.mdc)"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
actions_write_contents_read(perms) if {
|
|
103
|
+
perms.actions == "write"
|
|
104
|
+
perms.contents == "read"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
# --- jobs.cleanup_old_workflows.steps[0] ------------------------------------
|
|
108
|
+
|
|
109
|
+
step0 := input.jobs.cleanup_old_workflows.steps[0]
|
|
110
|
+
|
|
111
|
+
deny contains msg if {
|
|
112
|
+
step0.name != "Delete workflow runs"
|
|
113
|
+
msg := "clean-ga-workflows.yml: перший крок має мати name: Delete workflow runs (ga.mdc)"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
deny contains msg if {
|
|
117
|
+
step0.uses != "dmvict/clean-workflow-runs@v1"
|
|
118
|
+
msg := "clean-ga-workflows.yml: перший крок має uses: dmvict/clean-workflow-runs@v1 (ga.mdc)"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# Триплет полів `with`: token (gh-токен), save_period=31, save_min_runs_number=0.
|
|
122
|
+
# В JS-перевірці помилка спільна для всіх трьох — лишаємо такий самий формат, щоб
|
|
123
|
+
# повідомлення збігалися. Окремі правила нижче роблять діагноз точнішим.
|
|
124
|
+
deny contains msg if {
|
|
125
|
+
not step0_with_canonical
|
|
126
|
+
msg := "clean-ga-workflows.yml: with має містити token/save_period/save_min_runs_number як у ga.mdc"
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
step0_with_canonical if {
|
|
130
|
+
step0.with.token == expected_github_token
|
|
131
|
+
step0.with.save_period == 31
|
|
132
|
+
step0.with.save_min_runs_number == 0
|
|
133
|
+
}
|
package/scripts/check-abie.mjs
CHANGED
|
@@ -37,10 +37,17 @@
|
|
|
37
37
|
*
|
|
38
38
|
* **Service (overlay ru):** для кожного **Service**, оголошеного в YAML під **`…/k8s/…`**, де шлях **не** проходить через **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару, у т. ч. **headless** з **`clusterIP: None`** і **`-hl`**), якщо ще не **NodePort** / **LoadBalancer** / **ExternalName**,
|
|
39
39
|
* у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — **`op: remove`** для **`/spec/clusterIP`**; якщо в base **явно** задано **`spec.clusterIPs`** — також **`remove`** для **`/spec/clusterIPs`** (інакше **API** може залишити **`None`** для **NodePort**; без ключа **`clusterIPs`** у base **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
|
|
40
|
+
*
|
|
41
|
+
* **env→cluster DNS:** abie живе у трьох різних кластерах (GKE dev/ua + YC ru), тож DNS-суфікс і namespace-префікс у будь-якому
|
|
42
|
+
* **внутрішньокластерному** URL виду `http://<svc>.<ns>.svc.<dns>` мають відповідати імені env-файла. Скануються всі `*.env` файли,
|
|
43
|
+
* basename яких збігається з `dev.env` / `ua.env` / `ru.env` (опційно з провідною крапкою — `.dev.env` тощо). Для кожного знайденого
|
|
44
|
+
* internal URL у файлі (не лише `HASURA_GRAPHQL_ENDPOINT`, а й KVCMS, auth-run, file-link тощо) валідатор `validateAbieEnvInternalUrls`
|
|
45
|
+
* вимагає: для `dev.env` — DNS `abie-dev.internal` і namespace починається з `dev-`; для `ua.env` — `abie-ua.internal` + `ua-`;
|
|
46
|
+
* для `ru.env` — `cluster.local` + `ru-`. Файл `.env` без імені (локальний для розробника) виключено зі сканування — як і у `check-hasura.mjs`.
|
|
40
47
|
*/
|
|
41
48
|
import { existsSync } from 'node:fs'
|
|
42
49
|
import { readdir, readFile } from 'node:fs/promises'
|
|
43
|
-
import { dirname, join, relative } from 'node:path'
|
|
50
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
44
51
|
|
|
45
52
|
import { parseAllDocuments } from 'yaml'
|
|
46
53
|
|
|
@@ -119,6 +126,73 @@ const HTTPROUTE_BACKENDREF_PORT_8081_VALUE_FIRST_RE =
|
|
|
119
126
|
/** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
|
|
120
127
|
export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
|
|
121
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Регекс basename env-файлу abie: `dev.env` / `ua.env` / `ru.env`, опційно з провідною крапкою (`.dev.env` тощо).
|
|
131
|
+
* Файл рівно `.env` (без імені) — виключення з правила: локальний файл розробника, `check-abie` його не сканує
|
|
132
|
+
* (так само як `check-hasura`, див. `isEnvFile`).
|
|
133
|
+
*/
|
|
134
|
+
const ABIE_ENV_FILE_BASENAME_RE = /^\.?(dev|ua|ru)\.env$/u
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Глобальний регекс кластерного internal URL у тексті env-файлу.
|
|
138
|
+
* Використовується з `String.prototype.matchAll`, тому має флаг `g`.
|
|
139
|
+
* Дозволяє два DNS-формати: `<cluster>.internal` (GKE) і `cluster.local` (YC / стандартний k8s).
|
|
140
|
+
* Порт необов'язковий — у KVCMS-конфігах інколи лежить URL без порту (8080 додається сервісом за замовчуванням).
|
|
141
|
+
*/
|
|
142
|
+
const ABIE_INTERNAL_URL_GLOBAL_RE =
|
|
143
|
+
/\bhttp:\/\/([a-z0-9][a-z0-9-]*)\.([a-z0-9][a-z0-9-]*)\.svc\.((?:[a-z0-9][a-z0-9-]*\.internal)|cluster\.local)(?::\d+)?(?:\/[^\s"'`]*)?/giu
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Очікуваний кластерний DNS-суфікс і namespace-префікс для кожного env-файлу abie.
|
|
147
|
+
* `dev` / `ua` живуть у GKE з власним `<cluster>.internal`; `ru` — у YC, де DNS-суфікс
|
|
148
|
+
* стандартний `cluster.local` (без імені кластера).
|
|
149
|
+
*/
|
|
150
|
+
const ABIE_ENV_CLUSTER_DNS_MAP = Object.freeze({
|
|
151
|
+
dev: Object.freeze({ clusterDns: 'abie-dev.internal', namespacePrefix: 'dev-' }),
|
|
152
|
+
ua: Object.freeze({ clusterDns: 'abie-ua.internal', namespacePrefix: 'ua-' }),
|
|
153
|
+
ru: Object.freeze({ clusterDns: 'cluster.local', namespacePrefix: 'ru-' })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Дістає ім'я env (`dev` / `ua` / `ru`) з basename env-файлу abie.
|
|
158
|
+
* Для не-abie env-файлів (наприклад `production.env`, `.env` без імені) повертає `null`.
|
|
159
|
+
* @param {string} basenameOfEnvFile basename файла (без шляху)
|
|
160
|
+
* @returns {('dev' | 'ua' | 'ru') | null} ім'я env або `null`
|
|
161
|
+
*/
|
|
162
|
+
export function abieEnvNameFromBasename(basenameOfEnvFile) {
|
|
163
|
+
const m = basenameOfEnvFile.match(ABIE_ENV_FILE_BASENAME_RE)
|
|
164
|
+
return m ? /** @type {'dev' | 'ua' | 'ru'} */ (m[1]) : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Сканує вміст env-файлу abie і повертає помилки невідповідності кластерного DNS / namespace
|
|
169
|
+
* для кожного знайденого internal URL. URL шукається глобально (`matchAll`), тож одне й те саме
|
|
170
|
+
* порушення в кількох змінних дасть стільки ж окремих помилок.
|
|
171
|
+
* @param {string} content вміст env-файлу (UTF-8)
|
|
172
|
+
* @param {'dev' | 'ua' | 'ru'} envName ім'я env, отримане з `abieEnvNameFromBasename`
|
|
173
|
+
* @returns {string[]} порожній масив, якщо все OK; інакше — список повідомлень про порушення
|
|
174
|
+
*/
|
|
175
|
+
export function validateAbieEnvInternalUrls(content, envName) {
|
|
176
|
+
const expected = ABIE_ENV_CLUSTER_DNS_MAP[envName]
|
|
177
|
+
if (!expected) return []
|
|
178
|
+
/** @type {string[]} */
|
|
179
|
+
const errors = []
|
|
180
|
+
for (const match of content.matchAll(ABIE_INTERNAL_URL_GLOBAL_RE)) {
|
|
181
|
+
const [fullUrl, , namespace, clusterDns] = match
|
|
182
|
+
if (clusterDns !== expected.clusterDns) {
|
|
183
|
+
errors.push(
|
|
184
|
+
`${fullUrl}: кластерний DNS "${clusterDns}" не відповідає env "${envName}" (очікується "${expected.clusterDns}")`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
if (!namespace.startsWith(expected.namespacePrefix)) {
|
|
188
|
+
errors.push(
|
|
189
|
+
`${fullUrl}: namespace "${namespace}" не починається з "${expected.namespacePrefix}" (env "${envName}")`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return errors
|
|
194
|
+
}
|
|
195
|
+
|
|
122
196
|
/**
|
|
123
197
|
* Чи відносний шлях вказує на **`ru/kustomization.yaml`** (сегмент **`ru`** перед ім'ям файлу) — специфіка abie overlay.
|
|
124
198
|
* @param {string} rel шлях від кореня репозиторію
|
|
@@ -2078,6 +2152,70 @@ async function ensureAbieNginxSidecarForHasura(root, yamlFilesAbs, fail, passFn)
|
|
|
2078
2152
|
}
|
|
2079
2153
|
}
|
|
2080
2154
|
|
|
2155
|
+
/**
|
|
2156
|
+
* Збирає всі `*.env` файли в дереві (за виключенням `node_modules`, `.git` та інших службових каталогів),
|
|
2157
|
+
* basename яких — abie env-файл (`dev.env` / `ua.env` / `ru.env` опційно з провідною крапкою). Файл `.env`
|
|
2158
|
+
* без імені виключається — як і у `check-hasura.mjs`.
|
|
2159
|
+
* @param {string} root корінь репозиторію
|
|
2160
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
2161
|
+
* @returns {Promise<string[]>} відсортовані абсолютні шляхи env-файлів abie
|
|
2162
|
+
*/
|
|
2163
|
+
async function collectAbieEnvFiles(root, ignorePaths) {
|
|
2164
|
+
/** @type {string[]} */
|
|
2165
|
+
const out = []
|
|
2166
|
+
await walkDir(
|
|
2167
|
+
root,
|
|
2168
|
+
absPath => {
|
|
2169
|
+
if (abieEnvNameFromBasename(basename(absPath)) !== null) {
|
|
2170
|
+
out.push(absPath)
|
|
2171
|
+
}
|
|
2172
|
+
},
|
|
2173
|
+
ignorePaths
|
|
2174
|
+
)
|
|
2175
|
+
return out.toSorted((a, b) => a.localeCompare(b))
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
/**
|
|
2179
|
+
* Сканує всі `*.env` файли abie (`.dev.env` / `.ua.env` / `.ru.env`) і для кожного знайденого
|
|
2180
|
+
* **внутрішньокластерного** URL (`http://<svc>.<ns>.svc.<dns>`) перевіряє, що DNS-суфікс і namespace-префікс
|
|
2181
|
+
* відповідають середовищу env-файла. Не лише `HASURA_GRAPHQL_ENDPOINT`, а й будь-який сервіс у env (KVCMS,
|
|
2182
|
+
* `auth-run-hl`, `file-link-hl` тощо) мусить мати кластер, що відповідає env: dev → `abie-dev.internal`,
|
|
2183
|
+
* ua → `abie-ua.internal`, ru → `cluster.local`.
|
|
2184
|
+
* @param {string} root корінь репозиторію
|
|
2185
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
2186
|
+
* @param {(msg: string) => void} pass успішне повідомлення
|
|
2187
|
+
* @param {(msg: string) => void} fail повідомлення про порушення
|
|
2188
|
+
* @returns {Promise<void>}
|
|
2189
|
+
*/
|
|
2190
|
+
async function ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail) {
|
|
2191
|
+
const envFiles = await collectAbieEnvFiles(root, ignorePaths)
|
|
2192
|
+
if (envFiles.length === 0) {
|
|
2193
|
+
pass('Не знайдено dev.env / ua.env / ru.env у репозиторії — перевірку env→cluster DNS пропущено (abie.mdc)')
|
|
2194
|
+
return
|
|
2195
|
+
}
|
|
2196
|
+
for (const abs of envFiles) {
|
|
2197
|
+
const rel = relative(root, abs).replaceAll('\\', '/') || abs
|
|
2198
|
+
const envName = abieEnvNameFromBasename(basename(abs))
|
|
2199
|
+
if (envName === null) continue
|
|
2200
|
+
let raw
|
|
2201
|
+
try {
|
|
2202
|
+
raw = await readFile(abs, 'utf8')
|
|
2203
|
+
} catch (error) {
|
|
2204
|
+
const msg = error instanceof Error ? error.message : String(error)
|
|
2205
|
+
fail(`${rel}: не вдалося прочитати (${msg})`)
|
|
2206
|
+
continue
|
|
2207
|
+
}
|
|
2208
|
+
const errors = validateAbieEnvInternalUrls(raw, envName)
|
|
2209
|
+
if (errors.length === 0) {
|
|
2210
|
+
pass(`${rel}: усі внутрішні URL відповідають env "${envName}" (abie.mdc)`)
|
|
2211
|
+
} else {
|
|
2212
|
+
for (const err of errors) {
|
|
2213
|
+
fail(`${rel}: ${err} (abie.mdc)`)
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2081
2219
|
/**
|
|
2082
2220
|
* Перевіряє відповідність проєкту правилам abie.mdc.
|
|
2083
2221
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
@@ -2129,5 +2267,8 @@ export async function check() {
|
|
|
2129
2267
|
pass('Перевіряємо nginx-sidecar для Hasura WebSocket у ru (abie.mdc)')
|
|
2130
2268
|
await ensureAbieNginxSidecarForHasura(root, yamlFiles, fail, pass)
|
|
2131
2269
|
|
|
2270
|
+
pass('Перевіряємо env→cluster DNS у dev.env / ua.env / ru.env (abie.mdc)')
|
|
2271
|
+
await ensureAbieEnvFilesMatchClusterDns(root, ignorePaths, pass, fail)
|
|
2272
|
+
|
|
2132
2273
|
return reporter.getExitCode()
|
|
2133
2274
|
}
|
package/scripts/check-hasura.mjs
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
* вказує на `https://github.com/nitra/...` або `https://github.com/abinbevefes/...`
|
|
8
8
|
* (інші репозиторії пропускаються без помилок — як у check-abie).
|
|
9
9
|
*
|
|
10
|
-
* Очікуваний формат URL
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* Очікуваний формат URL — два варіанти кластерного DNS-суфікса:
|
|
11
|
+
* - GKE / GCP: `http://<service>.<namespace>.svc.<cluster>.internal:<port>`
|
|
12
|
+
* приклад: `http://contract-h.ua-contract.svc.abie-ua.internal:8080`
|
|
13
|
+
* - стандартний k8s / Yandex Cloud: `http://<service>.<namespace>.svc.cluster.local:<port>`
|
|
14
|
+
* приклад: `http://apruv-h-hl.ru-apruv.svc.cluster.local:8080`
|
|
14
15
|
*
|
|
15
16
|
* Сегменти беруться з `hasura/k8s/base/svc-hl.yaml` (`metadata.name` —
|
|
16
17
|
* має закінчуватись на `-h`, headless-сервіс) і `hasura/k8s/base/namespace.yaml`
|
|
@@ -42,12 +43,20 @@ const HASURA_NAMESPACE_FILE = `${HASURA_BASE_DIR}/namespace.yaml`
|
|
|
42
43
|
|
|
43
44
|
const ENV_FILE_RE = /\.env$/u
|
|
44
45
|
const HASURA_ENDPOINT_LINE_RE = /^[ \t]*(?:export[ \t]+)?HASURA_GRAPHQL_ENDPOINT[ \t]*=[ \t]*['"]?([^'"\r\n#]+)/mu
|
|
45
|
-
|
|
46
|
+
// Дозволяємо два DNS-суфікси кластера: `<name>.internal` (GKE/GCP) і `cluster.local`
|
|
47
|
+
// (стандартний k8s / Yandex Cloud). У YC namespace.yaml + cluster mode дають коротший суфікс.
|
|
48
|
+
const INTERNAL_HASURA_URL_RE =
|
|
49
|
+
/^http:\/\/([^./]+)\.([^./]+)\.svc\.((?:[^./:]+\.internal)|cluster\.local):(\d+)\/?$/u
|
|
50
|
+
const CLUSTER_LOCAL_SUFFIX = 'cluster.local'
|
|
51
|
+
const INTERNAL_DNS_SUFFIX = '.internal'
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Розбір значення `HASURA_GRAPHQL_ENDPOINT` як внутрішнього кластерного URL.
|
|
49
|
-
* Дозволяє лише `http://` (TLS усередині кластера зайвий)
|
|
50
|
-
* `<
|
|
55
|
+
* Дозволяє лише `http://` (TLS усередині кластера зайвий) та обидва кластерні
|
|
56
|
+
* DNS-суфікси: `<cluster>.internal` (GKE/GCP) і `cluster.local`
|
|
57
|
+
* (стандартний k8s / Yandex Cloud). Поле `cluster` для GKE містить ім'я
|
|
58
|
+
* кластера без `.internal` (наприклад `abie-ua`); для YC — повний суфікс
|
|
59
|
+
* `cluster.local` (бо своєї «назви кластера» в DNS немає).
|
|
51
60
|
* @param {string} url значення з `.env` (без огорнутих лапок)
|
|
52
61
|
* @returns {{ ok: true, service: string, namespace: string, cluster: string, port: string } | { ok: false }}
|
|
53
62
|
* розібрані сегменти або `{ ok: false }`, якщо формат не відповідає внутрішньому кластерному URL
|
|
@@ -57,7 +66,9 @@ export function parseInternalHasuraEndpoint(url) {
|
|
|
57
66
|
if (!m) {
|
|
58
67
|
return { ok: false }
|
|
59
68
|
}
|
|
60
|
-
|
|
69
|
+
const suffix = m[3]
|
|
70
|
+
const cluster = suffix.endsWith(INTERNAL_DNS_SUFFIX) ? suffix.slice(0, -INTERNAL_DNS_SUFFIX.length) : suffix
|
|
71
|
+
return { ok: true, service: m[1], namespace: m[2], cluster, port: m[4] }
|
|
61
72
|
}
|
|
62
73
|
|
|
63
74
|
/**
|
|
@@ -140,7 +151,7 @@ async function checkEnvFile(relPath, expected, reporter) {
|
|
|
140
151
|
const parsed = parseInternalHasuraEndpoint(value)
|
|
141
152
|
if (!parsed.ok) {
|
|
142
153
|
// eslint-disable-next-line @microsoft/sdl/no-insecure-url, sonarjs/no-clear-text-protocols -- hasura.mdc вимагає саме http:// для кластерного URL (TLS не використовується)
|
|
143
|
-
const example = 'http://<service>.<namespace>.svc.<cluster>.internal:<port>'
|
|
154
|
+
const example = 'http://<service>.<namespace>.svc.<cluster>.internal:<port> або http://<service>.<namespace>.svc.cluster.local:<port>'
|
|
144
155
|
fail(
|
|
145
156
|
`${relPath}: HASURA_GRAPHQL_ENDPOINT="${value}" — потрібен внутрішній кластерний URL виду ${example} (hasura.mdc)`
|
|
146
157
|
)
|
package/scripts/check-k8s.mjs
CHANGED
|
@@ -49,6 +49,13 @@
|
|
|
49
49
|
* завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує). У **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**
|
|
50
50
|
* перелік **`resources:`** (лише непорожні рядки) має бути відсортовано за алфавітом (**en**, `localeCompare`).
|
|
51
51
|
*
|
|
52
|
+
* **Структурний сорт `patches[]` у kustomization.yaml:** масив **`patches`** має бути відсортовано за tuple
|
|
53
|
+
* **`[target.kind, target.name, target.namespace, path]`** (`localeCompare('en', base)`). Поля **`group`** / **`version`**
|
|
54
|
+
* у tuple не входять — для них діє правило «patches[].target: лише kind і name». Додатково: вміст
|
|
55
|
+
* **inline `patches[i].patch`** (literal block scalar — масив JSON6902-операцій) має бути відсортовано за **`path`**,
|
|
56
|
+
* але **лише** якщо всі операції — **`add`** / **`replace`** і всі **`path`** попарно дизʼюнктні (жоден не префікс іншого).
|
|
57
|
+
* Інакше порядок не чіпається — `move` / `copy` / `test` / `remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
|
|
58
|
+
*
|
|
52
59
|
* **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
|
|
53
60
|
* на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
|
|
54
61
|
*
|
|
@@ -471,6 +478,197 @@ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFile
|
|
|
471
478
|
}
|
|
472
479
|
}
|
|
473
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Лексичне порівняння двох тuplіе рядків через `localeCompare('en', { sensitivity: 'base' })`.
|
|
483
|
+
* Менший за довжиною список доповнюється порожніми рядками.
|
|
484
|
+
* @param {string[]} a перший tuple
|
|
485
|
+
* @param {string[]} b другий tuple
|
|
486
|
+
* @returns {number} `< 0` якщо `a` менший, `> 0` якщо більший, `0` — рівні
|
|
487
|
+
*/
|
|
488
|
+
function compareStringTuplesEn(a, b) {
|
|
489
|
+
const n = Math.max(a.length, b.length)
|
|
490
|
+
for (let i = 0; i < n; i++) {
|
|
491
|
+
const av = a[i] ?? ''
|
|
492
|
+
const bv = b[i] ?? ''
|
|
493
|
+
const c = av.localeCompare(bv, 'en', { sensitivity: 'base' })
|
|
494
|
+
if (c !== 0) return c
|
|
495
|
+
}
|
|
496
|
+
return 0
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Чи послідовність tuple-ключів відсортована за `compareStringTuplesEn`.
|
|
501
|
+
* @param {string[][]} tuples масив tuple-ключів у порядку, як у файлі
|
|
502
|
+
* @returns {boolean} true, якщо порядок неспадний
|
|
503
|
+
*/
|
|
504
|
+
function stringTuplesAreSortedEn(tuples) {
|
|
505
|
+
for (let i = 1; i < tuples.length; i++) {
|
|
506
|
+
if (compareStringTuplesEn(tuples[i - 1], tuples[i]) > 0) return false
|
|
507
|
+
}
|
|
508
|
+
return true
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Tuple-ключ для сортування одного запису `patches[]` Kustomization.
|
|
513
|
+
* Порядок ключів: `target.kind` → `target.name` → `target.namespace` → `path`. Відсутні поля = `''`
|
|
514
|
+
* (порожні раніше за заповнені у `localeCompare` — стабільний детермінізм).
|
|
515
|
+
* Поля `target.group` / `target.version` навмисно не входять у ключ — у repo діє правило
|
|
516
|
+
* «patches[].target: лише kind і name», тому опертися на них не можна.
|
|
517
|
+
* @param {unknown} patchItem елемент масиву `patches[]`
|
|
518
|
+
* @returns {string[]} tuple для порівняння
|
|
519
|
+
*/
|
|
520
|
+
function kustomizationPatchSortKey(patchItem) {
|
|
521
|
+
if (patchItem === null || typeof patchItem !== 'object' || Array.isArray(patchItem)) {
|
|
522
|
+
return ['', '', '', '']
|
|
523
|
+
}
|
|
524
|
+
const rec = /** @type {Record<string, unknown>} */ (patchItem)
|
|
525
|
+
const t = rec.target
|
|
526
|
+
/** @type {Record<string, unknown>} */
|
|
527
|
+
const target = t !== null && typeof t === 'object' && !Array.isArray(t) ? /** @type {Record<string, unknown>} */ (t) : {}
|
|
528
|
+
const kind = typeof target.kind === 'string' ? target.kind : ''
|
|
529
|
+
const name = typeof target.name === 'string' ? target.name : ''
|
|
530
|
+
const ns = typeof target.namespace === 'string' ? target.namespace : ''
|
|
531
|
+
const path = typeof rec.path === 'string' ? rec.path : ''
|
|
532
|
+
return [kind, name, ns, path]
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Короткий ярлик запису `patches[]` для звітів («kind/name», або «path=…», або «#i»).
|
|
537
|
+
* @param {unknown} patchItem елемент масиву
|
|
538
|
+
* @param {number} i індекс у масиві (для fallback)
|
|
539
|
+
* @returns {string} людинозрозумілий ярлик
|
|
540
|
+
*/
|
|
541
|
+
function kustomizationPatchLabel(patchItem, i) {
|
|
542
|
+
const [kind, name, , path] = kustomizationPatchSortKey(patchItem)
|
|
543
|
+
if (kind && name) return `${kind}/${name}`
|
|
544
|
+
if (path) return `path=${path}`
|
|
545
|
+
return `#${i}`
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Порушення сорту **`patches[]`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
|
|
550
|
+
* Сортування за tuple `[target.kind, target.name, target.namespace, path]` (`localeCompare('en', base)`).
|
|
551
|
+
* @param {unknown} obj корінь першого YAML-документа kustomization.yaml
|
|
552
|
+
* @returns {string | null} причина або `null`, якщо обмеження не застосовується чи порядок ОК
|
|
553
|
+
*/
|
|
554
|
+
export function kustomizationPatchesSortedViolation(obj) {
|
|
555
|
+
if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
|
|
556
|
+
const rec = /** @type {Record<string, unknown>} */ (obj)
|
|
557
|
+
if (rec.kind !== 'Kustomization') return null
|
|
558
|
+
const av = rec.apiVersion
|
|
559
|
+
if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
|
|
560
|
+
const patches = rec.patches
|
|
561
|
+
if (patches === undefined) return null
|
|
562
|
+
if (!Array.isArray(patches)) {
|
|
563
|
+
return 'Kustomization.patches має бути масивом (k8s.mdc)'
|
|
564
|
+
}
|
|
565
|
+
if (patches.length < 2) return null
|
|
566
|
+
const keys = patches.map(p => kustomizationPatchSortKey(p))
|
|
567
|
+
if (stringTuplesAreSortedEn(keys)) return null
|
|
568
|
+
const order = patches.map((p, i) => ({ p, i, key: keys[i] }))
|
|
569
|
+
order.sort((a, b) => compareStringTuplesEn(a.key, b.key) || a.i - b.i)
|
|
570
|
+
const have = patches.map((p, i) => kustomizationPatchLabel(p, i)).join(', ')
|
|
571
|
+
const want = order.map(x => kustomizationPatchLabel(x.p, x.i)).join(', ')
|
|
572
|
+
return `Kustomization.patches має бути за алфавітом (target.kind → target.name → target.namespace → path). Зараз: ${have}; очікувано: ${want} (k8s.mdc)`
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Чи рядок виглядає як JSON-Pointer-шлях `/…` (порожнє і `/` теж приймаються — `/` = корінь). */
|
|
576
|
+
const JSON_POINTER_RE = /^\/[^\s]*$|^$|^\/$/u
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Чи кожен `path` у наборі — окремий вузол JSON-Pointer (немає прямого префікс-збігу типу `/spec` vs `/spec/replicas`).
|
|
580
|
+
* Однакові `path` теж вважаються «недизʼюнктними». Реалізація: `O(n²)` достатня для розмірів реальних patch-наборів.
|
|
581
|
+
* @param {string[]} paths шляхи у тому ж порядку, що й у файлі
|
|
582
|
+
* @returns {boolean} true, якщо всі шляхи попарно дизʼюнктні
|
|
583
|
+
*/
|
|
584
|
+
function jsonPointerPathsAreDisjoint(paths) {
|
|
585
|
+
for (let i = 0; i < paths.length; i++) {
|
|
586
|
+
for (let j = 0; j < paths.length; j++) {
|
|
587
|
+
if (i === j) continue
|
|
588
|
+
if (paths[i] === paths[j]) return false
|
|
589
|
+
if (paths[j].startsWith(`${paths[i]}/`)) return false
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return true
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Парсить рядок JSON6902-патчa в плоский масив операцій `{ op, path }` (без значень).
|
|
597
|
+
* Повертає `null`, якщо це не YAML-масив об'єктів з полями `op`/`path` як рядки.
|
|
598
|
+
* @param {string} raw тіло inline `patch:` (literal block scalar)
|
|
599
|
+
* @returns {{ op: string, path: string }[] | null} нормалізований список ops або `null` за невідповідного формату
|
|
600
|
+
*/
|
|
601
|
+
function parseJson6902OpsFromText(raw) {
|
|
602
|
+
let parsed
|
|
603
|
+
try {
|
|
604
|
+
parsed = parseDocument(raw).toJSON()
|
|
605
|
+
} catch {
|
|
606
|
+
return null
|
|
607
|
+
}
|
|
608
|
+
if (!Array.isArray(parsed)) return null
|
|
609
|
+
/** @type {{ op: string, path: string }[]} */
|
|
610
|
+
const out = []
|
|
611
|
+
for (const item of parsed) {
|
|
612
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return null
|
|
613
|
+
const rec = /** @type {Record<string, unknown>} */ (item)
|
|
614
|
+
if (typeof rec.op !== 'string' || typeof rec.path !== 'string') return null
|
|
615
|
+
out.push({ op: rec.op, path: rec.path })
|
|
616
|
+
}
|
|
617
|
+
return out
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Порушення сорту inline JSON6902-ops у одному `patches[i].patch`.
|
|
622
|
+
* Сортуємо **тільки** «безпечний» набір: всі `op ∈ { add, replace }` і всі `path` дизʼюнктні
|
|
623
|
+
* (немає префікс-зв'язку між шляхами). Інакше повертаємо `null` — порядок зберігаємо як у файлі,
|
|
624
|
+
* бо `move`/`copy`/`test`/`remove` чи спільні шляхи можуть бути семантично залежні (RFC 6902).
|
|
625
|
+
* @param {string} patchText вміст literal block (inline `patch:`)
|
|
626
|
+
* @returns {string | null} опис порушення або `null`
|
|
627
|
+
*/
|
|
628
|
+
export function kustomizationInlinePatchOpsSortedViolation(patchText) {
|
|
629
|
+
const ops = parseJson6902OpsFromText(patchText)
|
|
630
|
+
if (ops === null) return null
|
|
631
|
+
if (ops.length < 2) return null
|
|
632
|
+
for (const o of ops) {
|
|
633
|
+
if (o.op !== 'add' && o.op !== 'replace') return null
|
|
634
|
+
if (!JSON_POINTER_RE.test(o.path)) return null
|
|
635
|
+
}
|
|
636
|
+
const paths = ops.map(o => o.path)
|
|
637
|
+
if (!jsonPointerPathsAreDisjoint(paths)) return null
|
|
638
|
+
/** @type {string[][]} */
|
|
639
|
+
const keys = paths.map(p => [p])
|
|
640
|
+
if (stringTuplesAreSortedEn(keys)) return null
|
|
641
|
+
const want = paths.toSorted((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
|
|
642
|
+
return `inline patch (JSON6902) має бути за алфавітом по path. Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Усі **`kustomization.yaml`**: `patches[]` відсортовано за `[target.kind, target.name, …]`,
|
|
647
|
+
* а вміст inline `patches[i].patch` (де всі ops — `add`/`replace` і шляхи дизʼюнктні) — за `path`.
|
|
648
|
+
* @param {string} root корінь репо
|
|
649
|
+
* @param {string[]} yamlFilesAbs yaml під k8s
|
|
650
|
+
* @param {(msg: string) => void} fail функція для фіксації порушення
|
|
651
|
+
* @returns {Promise<void>} завершується після перевірки всіх kustomization.yaml
|
|
652
|
+
*/
|
|
653
|
+
async function validateKustomizationPatchesStructuralSort(root, yamlFilesAbs, fail) {
|
|
654
|
+
for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
|
|
655
|
+
const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
|
|
656
|
+
const kust = await readFirstYamlObject(kustAbs)
|
|
657
|
+
if (kust === null) continue
|
|
658
|
+
const outer = kustomizationPatchesSortedViolation(kust)
|
|
659
|
+
if (outer !== null) fail(`${rel}: ${outer}`)
|
|
660
|
+
const patches = kust.patches
|
|
661
|
+
if (!Array.isArray(patches)) continue
|
|
662
|
+
for (const [i, p] of patches.entries()) {
|
|
663
|
+
if (p === null || typeof p !== 'object' || Array.isArray(p)) continue
|
|
664
|
+
const rec = /** @type {Record<string, unknown>} */ (p)
|
|
665
|
+
if (typeof rec.patch !== 'string') continue
|
|
666
|
+
const v = kustomizationInlinePatchOpsSortedViolation(rec.patch)
|
|
667
|
+
if (v !== null) fail(`${rel}: patches[${i}] (${kustomizationPatchLabel(p, i)}): ${v}`)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
474
672
|
/**
|
|
475
673
|
* Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
|
|
476
674
|
* @param {unknown} obj корінь першого документа Kustomization
|
|
@@ -5916,6 +6114,8 @@ export async function check() {
|
|
|
5916
6114
|
|
|
5917
6115
|
await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
|
|
5918
6116
|
|
|
6117
|
+
await validateKustomizationPatchesStructuralSort(root, yamlFiles, fail)
|
|
6118
|
+
|
|
5919
6119
|
await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
|
|
5920
6120
|
|
|
5921
6121
|
await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
|
package/scripts/lint-ga.mjs
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CLI-обгортка над канонічним `lint-ga` (ga.mdc): робить preflight на `shellcheck` і `uv` (для `uvx`),
|
|
3
|
-
* тоді послідовно виконує `bunx github-actionlint
|
|
3
|
+
* тоді послідовно виконує `bunx github-actionlint`, `uvx zizmor --offline --collect=workflows .` і
|
|
4
|
+
* (PoC) `conftest test` на структуру канонічних workflow проти Rego-полісі з `npm/policy/ga/`.
|
|
5
|
+
*
|
|
6
|
+
* Conftest-крок навмисно **не** додається в preflight: якщо бінарник не встановлений, виводимо `ℹ`
|
|
7
|
+
* повідомлення й продовжуємо з кодом 0. Структурні перевірки тих самих workflow паралельно живуть у
|
|
8
|
+
* `npm/scripts/check-ga.mjs`, тож відсутність conftest не пропускає порушення мовчки.
|
|
4
9
|
*
|
|
5
10
|
* Без preflight `actionlint` (через `bunx github-actionlint`) мовчки пропускає shell-перевірки в
|
|
6
11
|
* `run:` блоках, коли `shellcheck` відсутній у PATH; локально `bun lint-ga` лишається зеленим, а CI
|
|
@@ -11,11 +16,29 @@
|
|
|
11
16
|
*
|
|
12
17
|
* Експортовано окремо `runLintGaCli` — використовується з `bin/n-cursor.js` як підкоманда `lint-ga`.
|
|
13
18
|
*/
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
14
20
|
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { dirname, join } from 'node:path'
|
|
15
22
|
import { platform } from 'node:process'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
16
24
|
|
|
17
25
|
import { resolveCmd } from './utils/resolve-cmd.mjs'
|
|
18
26
|
|
|
27
|
+
/** Каталог пакету `@nitra/cursor`, від якого ресолвимо вшиту директорію policy/. */
|
|
28
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
29
|
+
|
|
30
|
+
/** Шлях до Rego-полісі (PoC: лише clean-ga-workflows). У npm-tarball публікується через `files` у package.json. */
|
|
31
|
+
const GA_POLICY_DIR = join(PACKAGE_ROOT, 'policy', 'ga')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Workflow-файли, для яких маємо відповідну Rego-полісі. PoC: один файл; інші підтягуватимемо в міру міграції
|
|
35
|
+
* перевірок із `npm/scripts/check-ga.mjs`.
|
|
36
|
+
* @type {Array<{ workflow: string, label: string }>}
|
|
37
|
+
*/
|
|
38
|
+
const CONFTEST_TARGETS = [
|
|
39
|
+
{ workflow: '.github/workflows/clean-ga-workflows.yml', label: 'clean-ga-workflows.yml structure' }
|
|
40
|
+
]
|
|
41
|
+
|
|
19
42
|
/**
|
|
20
43
|
* Опис залежності preflight-ом: бінарник, для чого потрібен, і команди встановлення.
|
|
21
44
|
* @typedef {object} PreflightDep
|
|
@@ -153,5 +176,51 @@ export function runLintGaCli() {
|
|
|
153
176
|
if (actionlintCode !== 0) return actionlintCode
|
|
154
177
|
|
|
155
178
|
const zizmorCode = runStep('zizmor', 'uvx', ['zizmor', '--offline', '--collect=workflows', '.'])
|
|
156
|
-
return zizmorCode
|
|
179
|
+
if (zizmorCode !== 0) return zizmorCode
|
|
180
|
+
|
|
181
|
+
return runConftestStep()
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* PoC-крок: запускає conftest на YAML workflow проти Rego-полісі з пакету (`policy/ga/`).
|
|
186
|
+
*
|
|
187
|
+
* Поведінка fallback:
|
|
188
|
+
* - якщо `conftest` не знайдено в PATH — друкуємо `ℹ` повідомлення з підказкою встановлення й
|
|
189
|
+
* повертаємо 0 (тобто конфтест поки що **не** є обовʼязковою залежністю lint-ga; перевірки лежать
|
|
190
|
+
* паралельно в `check-ga.mjs`, і `npx @nitra/cursor check ga` все одно їх запустить);
|
|
191
|
+
* - якщо `conftest` є й полісі-каталог відсутній (нетипова інсталяція) — також `ℹ` skip;
|
|
192
|
+
* - якщо є цільовий workflow і conftest — запускаємо `conftest test <workflow> -p <policy-dir>` і
|
|
193
|
+
* повертаємо його exit-код, щоб порушення зупиняли lint-ga, як це робить actionlint/zizmor.
|
|
194
|
+
*
|
|
195
|
+
* Локальний `conftest` встановлюється через `brew install conftest` / `go install ...` — деталі в
|
|
196
|
+
* https://www.conftest.dev/install/.
|
|
197
|
+
* @returns {number} 0 — OK або skip, інакше — exit-код conftest
|
|
198
|
+
*/
|
|
199
|
+
function runConftestStep() {
|
|
200
|
+
const conftestBin = resolveCmd('conftest')
|
|
201
|
+
if (!conftestBin) {
|
|
202
|
+
console.log(
|
|
203
|
+
'\nℹ conftest не знайдено в PATH — пропускаю PoC-перевірку структури workflow через Rego-полісі.\n' +
|
|
204
|
+
' Встанови, щоб запустити її локально: brew install conftest (macOS) або https://www.conftest.dev/install/'
|
|
205
|
+
)
|
|
206
|
+
return 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!existsSync(GA_POLICY_DIR)) {
|
|
210
|
+
console.log(`\nℹ Каталог Rego-полісі не знайдено (${GA_POLICY_DIR}) — пропускаю conftest.`)
|
|
211
|
+
return 0
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const target of CONFTEST_TARGETS) {
|
|
215
|
+
if (!existsSync(target.workflow)) continue
|
|
216
|
+
const code = runStep(`conftest (${target.label})`, conftestBin, [
|
|
217
|
+
'test',
|
|
218
|
+
target.workflow,
|
|
219
|
+
'-p',
|
|
220
|
+
GA_POLICY_DIR,
|
|
221
|
+
'--no-color'
|
|
222
|
+
])
|
|
223
|
+
if (code !== 0) return code
|
|
224
|
+
}
|
|
225
|
+
return 0
|
|
157
226
|
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Запуск `regal lint` по Rego-полісі репозиторію (`conftest.mdc`).
|
|
3
|
+
*
|
|
4
|
+
* Regal (https://docs.styra.com/regal) — статичний лінтер Rego, який ловить v0-синтаксис,
|
|
5
|
+
* неявні set-rules та інші відхилення від `rego.v1`. Без preflight-у на наявність бінарника
|
|
6
|
+
* лінт мовчки злетить з невиразним повідомленням від shell — тут друкуємо явний install-hint
|
|
7
|
+
* (як це робить `lint-ga.mjs` для shellcheck/uv).
|
|
8
|
+
*
|
|
9
|
+
* Цілі лінту: `npm/policy/` (місце, де поки що живуть Rego-полісі пакета `@nitra/cursor`).
|
|
10
|
+
* Якщо в репозиторії з’являться інші *.rego поза цим деревом, додай шлях у `LINT_TARGETS` —
|
|
11
|
+
* `regal lint` приймає кілька шляхів і сам рекурсивно обходить директорії.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process'
|
|
14
|
+
import { existsSync } from 'node:fs'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { resolveCmd } from './utils/resolve-cmd.mjs'
|
|
18
|
+
|
|
19
|
+
/** Шляхи з Rego-полісі (відносно cwd). Існують не всі на ранніх стадіях — фільтруємо нижче. */
|
|
20
|
+
const LINT_TARGETS = ['npm/policy']
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Друкує підказку зі встановлення `regal`.
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
function printRegalInstallHints() {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
[
|
|
29
|
+
'❌ regal не знайдено в PATH.',
|
|
30
|
+
' Без нього не перевіряється rego.v1 синтаксис у *.rego (правило `conftest`).',
|
|
31
|
+
' Встанови:',
|
|
32
|
+
' macOS: brew install regal',
|
|
33
|
+
' Universal: https://docs.styra.com/regal#installation',
|
|
34
|
+
''
|
|
35
|
+
].join('\n')
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Запускає `regal lint` по існуючих цілях. Якщо жодної цілі немає — пропускає лінт із кодом 0.
|
|
41
|
+
* @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
|
|
42
|
+
* @returns {number} 0 — OK або skip; інакше код виходу regal
|
|
43
|
+
*/
|
|
44
|
+
export function runLintRego(cwd = process.cwd()) {
|
|
45
|
+
const root = resolve(cwd)
|
|
46
|
+
const regal = resolveCmd('regal')
|
|
47
|
+
if (!regal) {
|
|
48
|
+
printRegalInstallHints()
|
|
49
|
+
return 1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const targets = LINT_TARGETS.filter(rel => existsSync(resolve(root, rel)))
|
|
53
|
+
if (targets.length === 0) {
|
|
54
|
+
return 0
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.log(`▶ regal lint ${targets.join(' ')}`)
|
|
58
|
+
const result = spawnSync(regal, ['lint', ...targets], {
|
|
59
|
+
cwd: root,
|
|
60
|
+
stdio: 'inherit',
|
|
61
|
+
env: process.env
|
|
62
|
+
})
|
|
63
|
+
if (result.error) {
|
|
64
|
+
process.stderr.write(`❌ Не вдалося запустити regal: ${result.error.message}\n`)
|
|
65
|
+
return 1
|
|
66
|
+
}
|
|
67
|
+
return result.status ?? 1
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.exitCode = runLintRego()
|