@7n/rules-ci-azure 1.2.1 → 1.4.0
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 +12 -0
- package/package.json +5 -2
- package/rules/azure-pipelines/main.mdc +14 -4
- package/rules/azure-pipelines/service_deploy_pipeline/concern.json +9 -0
- package/rules/azure-pipelines/service_deploy_pipeline/docs/fix-service_deploy_pipeline.md +31 -0
- package/rules/azure-pipelines/service_deploy_pipeline/docs/index.md +9 -0
- package/rules/azure-pipelines/service_deploy_pipeline/fix-service_deploy_pipeline.mjs +455 -0
- package/rules/azure-pipelines/service_deploy_pipeline/service_deploy_pipeline.mdc +21 -0
- package/rules/azure-pipelines/service_deploy_pipeline/service_deploy_pipeline.rego +281 -0
- package/rules/azure-pipelines/service_deploy_pipeline/template/deploy-service-pipeline.yml.snippet.yml +77 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Автоміграція легасі сервіс-pipeline-ів до канону: T0-фікс service_deploy_pipeline переписує `.azurepipelines/**` (job plan, легасі `lint --path` → per-domain `lint_<key>` по файлах піддерева, перешивка dependsOn і Skipped-толерантні condition) через yaml Document API зі збереженням коментарів
|
|
8
|
+
|
|
9
|
+
## [1.3.0] - 2026-07-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Сервіс-орієнтований CI-канон: концерн azure-pipelines/service_deploy_pipeline — форма per-service pipeline у .azurepipelines/ (plan-гейт `ci plan --azure`, per-domain lint-джоби з dependsOn+condition, термінальні джоби зі Skipped-толерантним condition)
|
|
14
|
+
|
|
3
15
|
## [1.2.1] - 2026-07-16
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/rules-ci-azure",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Плагін @7n/rules: канон Azure Pipelines (azure-pipelines.yml) — структура, lint-степ, VS Code",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"azure-pipelines",
|
|
7
7
|
"ci",
|
|
@@ -44,5 +44,8 @@
|
|
|
44
44
|
"engines": {
|
|
45
45
|
"bun": ">=1.3",
|
|
46
46
|
"node": ">=24"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"yaml": "^2.9.0"
|
|
47
50
|
}
|
|
48
51
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Канон Azure Pipelines — структура azure-pipelines.yml, обов'язковий lint-степ n-rules, розширення VS Code
|
|
3
|
-
version: '1.
|
|
4
|
-
globs: "azure-pipelines.yml"
|
|
2
|
+
description: Канон Azure Pipelines — структура azure-pipelines.yml, обов'язковий lint-степ n-rules, сервіс-орієнтовані deploy-pipelines, розширення VS Code
|
|
3
|
+
version: '1.1'
|
|
4
|
+
globs: "azure-pipelines.yml,.azurepipelines/**/*.yml"
|
|
5
5
|
alwaysApply: false
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
Правило **azure-pipelines** (плагін `@7n/rules-ci-azure`) перевіряє канонічну структуру `azure-pipelines.yml` у корені
|
|
8
|
+
Правило **azure-pipelines** (плагін `@7n/rules-ci-azure`) перевіряє канонічну структуру `azure-pipelines.yml` у корені репозиторію, форму сервіс-орієнтованих deploy-pipelines у `.azurepipelines/` та налаштування VS Code для роботи з Azure Pipelines. Аналог правила `ga` (плагін `@7n/rules-ci-github`) для репозиторіїв на Azure DevOps.
|
|
9
|
+
|
|
10
|
+
Ролі pipeline-ів: **кореневий `azure-pipelines.yml`** — repo-wide лінт (safety-net `--full`), деплой **не гейтить**; **сервісні pipelines** (`.azurepipelines/**/*.yml` із `trigger.paths.include`) — гейтять деплой свого сервісу через plan → lint/test → deploy (концерн `service_deploy_pipeline`).
|
|
9
11
|
|
|
10
12
|
- `azure-pipelines.yml`:
|
|
11
13
|
|
|
@@ -67,6 +69,14 @@ Substring-перевірки замість exact-match — Azure YAML допу
|
|
|
67
69
|
|
|
68
70
|
Канон: [azure-pipelines-lint.yml.snippet.yml](./lint_pipeline/template/azure-pipelines-lint.yml.snippet.yml)
|
|
69
71
|
|
|
72
|
+
## Сервіс-орієнтовані deploy-pipelines (`.azurepipelines/**/*.yml`, опційно)
|
|
73
|
+
|
|
74
|
+
Rego-пакет: `azure_pipelines.service_deploy_pipeline`
|
|
75
|
+
|
|
76
|
+
**Цільові файли:** `.azurepipelines/**/*.yml` крім `templates/**`; сервісним вважається файл із `trigger.paths.include`.
|
|
77
|
+
|
|
78
|
+
Форма: джоба `plan` (`bunx n-rules ci plan --path <svc> --azure`, крок з `name: plan`) → паралельні `lint_<domain>`-джоби (`dependsOn: plan`, `condition` по `dependencies.plan.outputs['plan.<domain>']`, `lint <domain> --path <svc> --no-fix`) і `run_tests` → термінальні джоби з dependsOn-ланцюгом на всі перевірки та Skipped-толерантним `condition`. Деталі й template-розкладка: [service_deploy_pipeline.mdc](./service_deploy_pipeline/service_deploy_pipeline.mdc); приклад — [deploy-service-pipeline.yml.snippet.yml](./service_deploy_pipeline/template/deploy-service-pipeline.yml.snippet.yml).
|
|
79
|
+
|
|
70
80
|
## `.vscode/extensions.json` — розширення для Azure Pipelines
|
|
71
81
|
|
|
72
82
|
Rego-пакет: `azure_pipelines.vscode_extensions`
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: JS Module
|
|
3
|
+
title: fix-service_deploy_pipeline.mjs
|
|
4
|
+
resource: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline/fix-service_deploy_pipeline.mjs
|
|
5
|
+
docgen:
|
|
6
|
+
crc: 7841b82d
|
|
7
|
+
model: openai-codex/gpt-5.4-mini
|
|
8
|
+
tier: cloud-min
|
|
9
|
+
score: 100
|
|
10
|
+
issues: judge:inaccurate:0.99
|
|
11
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Огляд
|
|
15
|
+
|
|
16
|
+
Файл існує для автоматичного переведення legacy `.azurepipelines/**.yml` у сервіс-канон ADR 260718-0835: він детерміновано переписує pipeline-и, де `trigger.paths.include` не відповідає формі `plan → lint_<domain> → deploy`, бо `lint --path` у `@7n/rules` 1.17 змінив семантику без major-бампа; `plan` додається через `bunx n-rules ci plan --path <svc> --azure`, а `lint_<key>` визначаються за файлами піддерева сервісу тими самими glob-ами, що й `ci plan`. Міграція зберігає коментарі та незачеплене форматування завдяки мутаціям через YAML Document API (`setIn`/`splice`). Також вона підшиває потрібні `dependsOn` і `condition` для нових lint-джоб і не перезаписує нетривіальний `condition`. Файл працює fail-safe: не кидає винятків назовні й за окремих помилок повертає порожнє значення замість exception.
|
|
17
|
+
|
|
18
|
+
## Поведінка
|
|
19
|
+
|
|
20
|
+
- `migratePipelineFile` — Мігрує один `.azurepipelines/**.yml` із `trigger.paths.include` до сервіс-канону: за потреби додає `plan`, замінює legacy `lint`-джоби на per-domain `lint_<key>`, підшиває `dependsOn`/`condition` і `--no-fix`, зберігаючи коментарі та форматування; якщо файл поза скоупом, `null` або міграція не вдається, повертає `false` без винятку назовні.
|
|
21
|
+
- `patterns` — Описує rule-pattern, який знаходить порушені service pipeline-и та застосовує автоматичну міграцію до кожного знайденого файлу; помилки окремих файлів перехоплює, а результатом повертає список змінених файлів і повідомлення про кількість мігрованих pipeline-ів.
|
|
22
|
+
|
|
23
|
+
## Публічний API
|
|
24
|
+
|
|
25
|
+
- migratePipelineFile — переносить один pipeline-файл у канонічний формат і сигналізує, чи були зміни.
|
|
26
|
+
- patterns — T0-патерн для fix-конвеєра: розпізнає порушені service pipeline-и й застосовує міграцію.
|
|
27
|
+
|
|
28
|
+
## Гарантії поведінки
|
|
29
|
+
|
|
30
|
+
- Перехоплює помилки і не пропускає винятків назовні (fail-safe).
|
|
31
|
+
- За певних помилок повертає порожнє значення (напр. `null`) замість винятку.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: Directory Index
|
|
3
|
+
title: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline
|
|
4
|
+
resource: plugins/ci-azure/rules/azure-pipelines/service_deploy_pipeline/
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
| Файл | Тип |
|
|
8
|
+
| ----------------------------------------------------------------- | --------- |
|
|
9
|
+
| [fix-service_deploy_pipeline.mjs](fix-service_deploy_pipeline.md) | JS Module |
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T0-автоміграція легасі сервіс-pipeline-ів до сервіс-канону (ADR 260718-0835).
|
|
3
|
+
*
|
|
4
|
+
* Детермінований переписувач `.azurepipelines/**.yml` із `trigger.paths.include`,
|
|
5
|
+
* що НЕ відповідають формі plan → lint_<domain> → deploy (порушення rego-концерну
|
|
6
|
+
* service_deploy_pipeline). Семантика `lint --path` змінилась у `@7n/rules` 1.17
|
|
7
|
+
* (перетин із git-дельтою) без major-бампа — замість ручної міграції консюмерів
|
|
8
|
+
* (efes: 18 пайплайнів) фікс переводить pipeline одразу в новий канон:
|
|
9
|
+
*
|
|
10
|
+
* - додає job `plan` (prep + `bunx n-rules ci plan --path <svc> --azure`, name: plan);
|
|
11
|
+
* - легасі job із `n-rules lint --path <svc>` (без домену) замінюється на
|
|
12
|
+
* per-domain джоби lint_<key> — домени визначаються по ФАЙЛАХ піддерева сервісу
|
|
13
|
+
* тими самими glob-ами, що й `ci plan` (computeActiveDomains/domainKey);
|
|
14
|
+
* - domain-style lint-джоби добираються wiring-ом (dependsOn: plan, condition по
|
|
15
|
+
* outputs, `--no-fix`, fetchDepth: 0);
|
|
16
|
+
* - dependsOn інших джоб перешивається з легасі-імені на нові lint-джоби; джоби
|
|
17
|
+
* з прямими deps на умовні lint-джоби без власного condition отримують
|
|
18
|
+
* Skipped-толерантний канон (`not(canceled())` + `in(…, 'Succeeded', 'Skipped')`).
|
|
19
|
+
*
|
|
20
|
+
* Мутації — через `yaml` Document API (setIn/splice item-ів), щоб зберегти
|
|
21
|
+
* коментарі та форматування незачеплених частин. Наявний нетривіальний
|
|
22
|
+
* `condition` НЕ перезаписується (rego лишає deny → ручне рішення автора).
|
|
23
|
+
* Template-розкладка (`- template:`) не мігрується — фіксеру не видно
|
|
24
|
+
* розгорнутих джоб.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
|
|
27
|
+
import { join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
import { parseDocument } from 'yaml'
|
|
30
|
+
|
|
31
|
+
import { loadEnabledLintRules, computeActiveDomains } from '@7n/rules/scripts/lib/lint-surface/run-detectors.mjs'
|
|
32
|
+
import { collectPathScopedFiles } from '@7n/rules/scripts/lib/lint-surface/path-scope.mjs'
|
|
33
|
+
import { domainKey } from '@7n/rules/scripts/lib/lint-surface/ci-plan.mjs'
|
|
34
|
+
|
|
35
|
+
const WS_RE = /\s+/u
|
|
36
|
+
|
|
37
|
+
/** Канонічні prep-кроки, якщо в pipeline немає власного зразка. */
|
|
38
|
+
const CANONICAL_PREP = [
|
|
39
|
+
{ checkout: 'self', fetchDepth: 0 },
|
|
40
|
+
{
|
|
41
|
+
script: 'curl -fsSL https://bun.sh/install | bash\necho "##vso[task.prependpath]$HOME/.bun/bin"\n',
|
|
42
|
+
displayName: 'Install bun'
|
|
43
|
+
},
|
|
44
|
+
{ script: 'bun install --frozen-lockfile', displayName: 'Install deps' }
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Текст команди кроку (`script:` або `bash:`), '' якщо крок не командний.
|
|
49
|
+
* @param {unknown} step крок джоби (plain JS)
|
|
50
|
+
* @returns {string} команда або ''
|
|
51
|
+
*/
|
|
52
|
+
function stepCmd(step) {
|
|
53
|
+
if (!step || typeof step !== 'object') return ''
|
|
54
|
+
const s = /** @type {Record<string, unknown>} */ (step)
|
|
55
|
+
if (typeof s.script === 'string') return s.script
|
|
56
|
+
if (typeof s.bash === 'string') return s.bash
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Розбирає команду `n-rules lint …`/`n-rules ci plan …` токенами (без regex —
|
|
62
|
+
* стійко до багаторядкових script-ів і без бектрекінгу).
|
|
63
|
+
* @param {string} cmd повний текст команди кроку
|
|
64
|
+
* @param {string} marker підрядок-початок (`n-rules lint` | `n-rules ci plan`)
|
|
65
|
+
* @returns {{ domain: string|null, path: string|null }|null} розбір або null, якщо маркера немає
|
|
66
|
+
*/
|
|
67
|
+
function parseNRulesCmd(cmd, marker) {
|
|
68
|
+
const at = cmd.indexOf(marker)
|
|
69
|
+
if (at === -1) return null
|
|
70
|
+
const tokens = cmd
|
|
71
|
+
.slice(at + marker.length)
|
|
72
|
+
.split(WS_RE)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
let domain = null
|
|
75
|
+
let path = null
|
|
76
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
77
|
+
const t = tokens[i]
|
|
78
|
+
if (t === '--path') {
|
|
79
|
+
path = tokens[i + 1] ?? null
|
|
80
|
+
i++
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
if (!t.startsWith('-') && domain === null && path === null) domain = t
|
|
84
|
+
}
|
|
85
|
+
return { domain, path }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Prep-кроки для нових джоб: зразок із першої джоби, що робить
|
|
90
|
+
* `bun install` (кроки від початку до нього включно, checkout → fetchDepth: 0),
|
|
91
|
+
* інакше — канонічний блок.
|
|
92
|
+
* @param {Array<Record<string, unknown>>} jobs всі джоби pipeline (plain JS)
|
|
93
|
+
* @returns {Array<Record<string, unknown>>} prep-кроки (plain JS, глибока копія)
|
|
94
|
+
*/
|
|
95
|
+
function derivePrepSteps(jobs) {
|
|
96
|
+
for (const job of jobs) {
|
|
97
|
+
const steps = Array.isArray(job.steps) ? job.steps : []
|
|
98
|
+
const idx = steps.findIndex(s => stepCmd(s).includes('bun install'))
|
|
99
|
+
if (idx === -1) continue
|
|
100
|
+
const prep = structuredClone(steps.slice(0, idx + 1))
|
|
101
|
+
const checkout = prep.find(s => s && typeof s === 'object' && 'checkout' in s)
|
|
102
|
+
if (checkout) checkout.fetchDepth = 0
|
|
103
|
+
else prep.unshift({ checkout: 'self', fetchDepth: 0 })
|
|
104
|
+
return prep
|
|
105
|
+
}
|
|
106
|
+
return structuredClone(CANONICAL_PREP)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Збирає всі jobs-послідовності документа: корінь `jobs` і `stages[i].jobs`.
|
|
111
|
+
* @param {import('yaml').Document} doc YAML-документ
|
|
112
|
+
* @returns {Array<{ path: Array<string|number>, items: unknown[] }>} шляхи та plain-JS елементи
|
|
113
|
+
*/
|
|
114
|
+
function collectJobSeqs(doc) {
|
|
115
|
+
const out = []
|
|
116
|
+
const js = doc.toJS() ?? {}
|
|
117
|
+
if (Array.isArray(js.jobs)) out.push({ path: ['jobs'], items: js.jobs })
|
|
118
|
+
if (Array.isArray(js.stages)) {
|
|
119
|
+
for (const [i, stage] of js.stages.entries()) {
|
|
120
|
+
if (stage && Array.isArray(stage.jobs)) out.push({ path: ['stages', i, 'jobs'], items: stage.jobs })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* dependsOn джоби як масив імен.
|
|
128
|
+
* @param {Record<string, unknown>} job джоба (plain JS)
|
|
129
|
+
* @returns {string[]} імена залежностей
|
|
130
|
+
*/
|
|
131
|
+
function dependsOf(job) {
|
|
132
|
+
const d = job.dependsOn
|
|
133
|
+
if (typeof d === 'string') return [d]
|
|
134
|
+
if (Array.isArray(d)) return d.filter(x => typeof x === 'string')
|
|
135
|
+
return []
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Канонічний Skipped-толерантний condition для джоби з прямими deps.
|
|
140
|
+
* @param {string[]} deps імена прямих залежностей
|
|
141
|
+
* @returns {string} вираз condition
|
|
142
|
+
*/
|
|
143
|
+
function skipTolerantCondition(deps) {
|
|
144
|
+
const parts = ['not(canceled())']
|
|
145
|
+
for (const d of deps) {
|
|
146
|
+
if (d === 'plan') parts.push("eq(dependencies.plan.result, 'Succeeded')")
|
|
147
|
+
else parts.push(`in(dependencies.${d}.result, 'Succeeded', 'Skipped')`)
|
|
148
|
+
}
|
|
149
|
+
return `and(${parts.join(', ')})`
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Нова per-domain lint-джоба канону.
|
|
154
|
+
* @param {string} domain rule-id домену
|
|
155
|
+
* @param {string} servicePath каталог сервісу
|
|
156
|
+
* @param {Array<Record<string, unknown>>} prep prep-кроки
|
|
157
|
+
* @returns {Record<string, unknown>} джоба (plain JS)
|
|
158
|
+
*/
|
|
159
|
+
function buildLintJob(domain, servicePath, prep) {
|
|
160
|
+
const key = domainKey(domain)
|
|
161
|
+
return {
|
|
162
|
+
job: `lint_${key}`,
|
|
163
|
+
dependsOn: 'plan',
|
|
164
|
+
condition: `eq(dependencies.plan.outputs['plan.${key}'], 'true')`,
|
|
165
|
+
steps: [
|
|
166
|
+
...structuredClone(prep),
|
|
167
|
+
{ script: `bunx n-rules lint ${domain} --path ${servicePath} --no-fix`, displayName: `Lint ${domain}` }
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Релевантні домени сервісу: enabled-правила, чиї per-file glob-и матчать
|
|
174
|
+
* бодай один файл піддерева (та сама таблиця, що `ci plan` по не-дельті).
|
|
175
|
+
* @param {string} cwd корінь consumer-репо
|
|
176
|
+
* @param {string} servicePath каталог сервісу
|
|
177
|
+
* @returns {Promise<string[]>} відсортовані rule-id
|
|
178
|
+
*/
|
|
179
|
+
async function relevantDomains(cwd, servicePath) {
|
|
180
|
+
const abs = resolve(cwd, servicePath)
|
|
181
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) return []
|
|
182
|
+
const files = await collectPathScopedFiles(cwd, servicePath)
|
|
183
|
+
const { byRule, enabledSet } = await loadEnabledLintRules({ cwd })
|
|
184
|
+
const active = computeActiveDomains(byRule, enabledSet, files)
|
|
185
|
+
return active
|
|
186
|
+
.entries()
|
|
187
|
+
.filter(([, st]) => st.triggered)
|
|
188
|
+
.map(([id]) => id)
|
|
189
|
+
.toArray()
|
|
190
|
+
.toSorted((a, b) => a.localeCompare(b))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Розбір lint-команди джоби: `{ legacy, domain, path, stepIndex }` або null.
|
|
195
|
+
* @param {Array<Record<string, unknown>>} steps кроки джоби (plain JS)
|
|
196
|
+
* @returns {{ legacy: boolean, domain: string|null, path: string|null, stepIndex: number }|null} розбір
|
|
197
|
+
*/
|
|
198
|
+
function findLintStep(steps) {
|
|
199
|
+
for (const [i, step] of steps.entries()) {
|
|
200
|
+
const cmd = stepCmd(step)
|
|
201
|
+
const parsed = parseNRulesCmd(cmd, 'n-rules lint')
|
|
202
|
+
if (!parsed || parsed.path === null) continue
|
|
203
|
+
return { legacy: parsed.domain === null, domain: parsed.domain, path: parsed.path, stepIndex: i }
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Крок 1: вставляє job `plan` на початок першої jobs-послідовності (якщо немає).
|
|
210
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
211
|
+
* @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
|
|
212
|
+
* @param {Array<Record<string, unknown>>} prep prep-кроки
|
|
213
|
+
* @param {string} servicePath каталог сервісу
|
|
214
|
+
* @returns {boolean} чи були зміни
|
|
215
|
+
*/
|
|
216
|
+
function ensurePlanJob(doc, seqs, prep, servicePath) {
|
|
217
|
+
const planNode = doc.createNode({
|
|
218
|
+
job: 'plan',
|
|
219
|
+
steps: [...structuredClone(prep), { script: `bunx n-rules ci plan --path ${servicePath} --azure`, name: 'plan' }]
|
|
220
|
+
})
|
|
221
|
+
doc.getIn(seqs[0].path).items.unshift(planNode)
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Крок 2: замінює легасі lint-джоби на per-domain джоби у своїх послідовностях.
|
|
227
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
228
|
+
* @param {Array<{ seq: { path: Array<string|number> }, job: Record<string, unknown> }>} legacy легасі-джоби
|
|
229
|
+
* @param {string[]} domains релевантні домени сервісу
|
|
230
|
+
* @param {string} servicePath каталог сервісу
|
|
231
|
+
* @param {Array<Record<string, unknown>>} prep prep-кроки
|
|
232
|
+
* @returns {{ renames: Map<string, string[]>, changed: boolean }} мапа перейменувань
|
|
233
|
+
*/
|
|
234
|
+
function replaceLegacyJobs(doc, legacy, domains, servicePath, prep) {
|
|
235
|
+
/** @type {Map<string, string[]>} */
|
|
236
|
+
const renames = new Map()
|
|
237
|
+
let changed = false
|
|
238
|
+
for (const { seq, job } of legacy) {
|
|
239
|
+
const newJobs = domains.map(d => buildLintJob(d, servicePath, prep))
|
|
240
|
+
const seqNode = doc.getIn(seq.path)
|
|
241
|
+
const docIndex = seqNode.items.findIndex(item => item?.toJS?.(doc)?.job === job.job)
|
|
242
|
+
if (docIndex === -1) continue
|
|
243
|
+
seqNode.items.splice(docIndex, 1, ...newJobs.map(j => doc.createNode(j)))
|
|
244
|
+
renames.set(
|
|
245
|
+
job.job,
|
|
246
|
+
newJobs.map(j => /** @type {string} */ (j.job))
|
|
247
|
+
)
|
|
248
|
+
changed = true
|
|
249
|
+
}
|
|
250
|
+
return { renames, changed }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Крок 3 (одна джоба): добирає wiring domain-style lint-джоби —
|
|
255
|
+
* dependsOn: plan, condition по outputs, `--no-fix`, fetchDepth: 0.
|
|
256
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
257
|
+
* @param {Array<string|number>} base шлях джоби в документі
|
|
258
|
+
* @param {Record<string, unknown>} j джоба (plain JS)
|
|
259
|
+
* @param {{ domain: string|null, stepIndex: number }} found розбір lint-кроку
|
|
260
|
+
* @returns {boolean} чи були зміни
|
|
261
|
+
*/
|
|
262
|
+
function patchDomainLintJob(doc, base, j, found) {
|
|
263
|
+
let changed = false
|
|
264
|
+
const steps = Array.isArray(j.steps) ? j.steps : []
|
|
265
|
+
if (dependsOf(j).length === 0) {
|
|
266
|
+
doc.setIn([...base, 'dependsOn'], 'plan')
|
|
267
|
+
changed = true
|
|
268
|
+
}
|
|
269
|
+
if (typeof j.condition !== 'string' && found.domain) {
|
|
270
|
+
doc.setIn([...base, 'condition'], `eq(dependencies.plan.outputs['plan.${domainKey(found.domain)}'], 'true')`)
|
|
271
|
+
changed = true
|
|
272
|
+
}
|
|
273
|
+
const cmd = stepCmd(steps[found.stepIndex])
|
|
274
|
+
if (!cmd.includes('--no-fix')) {
|
|
275
|
+
const key = typeof steps[found.stepIndex].script === 'string' ? 'script' : 'bash'
|
|
276
|
+
doc.setIn([...base, 'steps', found.stepIndex, key], `${cmd.trimEnd()} --no-fix`)
|
|
277
|
+
changed = true
|
|
278
|
+
}
|
|
279
|
+
const checkoutIdx = steps.findIndex(s => s && typeof s === 'object' && 'checkout' in s)
|
|
280
|
+
if (checkoutIdx !== -1 && steps[checkoutIdx].fetchDepth !== 0) {
|
|
281
|
+
doc.setIn([...base, 'steps', checkoutIdx, 'fetchDepth'], 0)
|
|
282
|
+
changed = true
|
|
283
|
+
}
|
|
284
|
+
return changed
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Крок 3 (обхід): знаходить усі domain-style lint-джоби, добирає wiring і
|
|
289
|
+
* доповнює множину імен lint-джоб.
|
|
290
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
291
|
+
* @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
|
|
292
|
+
* @param {Set<string>} lintJobNames множина lint-джоб (мутується)
|
|
293
|
+
* @returns {boolean} чи були зміни
|
|
294
|
+
*/
|
|
295
|
+
function patchDomainLintJobs(doc, seqs, lintJobNames) {
|
|
296
|
+
let changed = false
|
|
297
|
+
for (const seq of seqs) {
|
|
298
|
+
const seqNode = doc.getIn(seq.path)
|
|
299
|
+
for (const [i, itemNode] of seqNode.items.entries()) {
|
|
300
|
+
const j = itemNode?.toJS?.(doc)
|
|
301
|
+
if (!j || typeof j.job !== 'string' || j.job === 'plan') continue
|
|
302
|
+
const found = findLintStep(Array.isArray(j.steps) ? j.steps : [])
|
|
303
|
+
if (!found || found.legacy) continue
|
|
304
|
+
lintJobNames.add(j.job)
|
|
305
|
+
if (patchDomainLintJob(doc, [...seq.path, i], j, found)) changed = true
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return changed
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Крок 4: перешиває dependsOn (легасі-ім'я → нові джоби) і ставить
|
|
313
|
+
* Skipped-толерантний condition джобам із прямими deps на умовні lint-джоби
|
|
314
|
+
* (лише коли власного condition немає — нетривіальний не перезаписуємо).
|
|
315
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
316
|
+
* @param {Array<{ path: Array<string|number> }>} seqs jobs-послідовності
|
|
317
|
+
* @param {Map<string, string[]>} renames легасі-ім'я → нові lint-джоби
|
|
318
|
+
* @param {Set<string>} lintJobNames множина всіх lint-джоб
|
|
319
|
+
* @returns {boolean} чи були зміни
|
|
320
|
+
*/
|
|
321
|
+
function rewireDependents(doc, seqs, renames, lintJobNames) {
|
|
322
|
+
let changed = false
|
|
323
|
+
for (const seq of seqs) {
|
|
324
|
+
const seqNode = doc.getIn(seq.path)
|
|
325
|
+
for (const [i, itemNode] of seqNode.items.entries()) {
|
|
326
|
+
const j = itemNode?.toJS?.(doc)
|
|
327
|
+
if (!j || typeof j.job !== 'string' || j.job === 'plan' || lintJobNames.has(j.job)) continue
|
|
328
|
+
if (rewireOneJob(doc, [...seq.path, i], j, renames, lintJobNames)) changed = true
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return changed
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Крок 4 (одна джоба): перешивка dependsOn і Skipped-толерантний condition.
|
|
336
|
+
* @param {import('yaml').Document} doc документ (мутується)
|
|
337
|
+
* @param {Array<string|number>} base шлях джоби в документі
|
|
338
|
+
* @param {Record<string, unknown>} j джоба (plain JS)
|
|
339
|
+
* @param {Map<string, string[]>} renames легасі-ім'я → нові lint-джоби
|
|
340
|
+
* @param {Set<string>} lintJobNames множина всіх lint-джоб
|
|
341
|
+
* @returns {boolean} чи були зміни
|
|
342
|
+
*/
|
|
343
|
+
function rewireOneJob(doc, base, j, renames, lintJobNames) {
|
|
344
|
+
let deps = dependsOf(j)
|
|
345
|
+
if (deps.length === 0) return false
|
|
346
|
+
let changed = false
|
|
347
|
+
const expanded = deps.flatMap(d => renames.get(d) ?? [d])
|
|
348
|
+
if (JSON.stringify(expanded) !== JSON.stringify(deps)) {
|
|
349
|
+
doc.setIn([...base, 'dependsOn'], doc.createNode(expanded))
|
|
350
|
+
deps = expanded
|
|
351
|
+
changed = true
|
|
352
|
+
}
|
|
353
|
+
const touchesLint = deps.some(d => lintJobNames.has(d))
|
|
354
|
+
if (touchesLint && typeof j.condition !== 'string') {
|
|
355
|
+
const withPlan = deps.includes('plan') ? deps : ['plan', ...deps]
|
|
356
|
+
if (!deps.includes('plan')) doc.setIn([...base, 'dependsOn'], doc.createNode(withPlan))
|
|
357
|
+
doc.setIn([...base, 'condition'], skipTolerantCondition(withPlan))
|
|
358
|
+
changed = true
|
|
359
|
+
}
|
|
360
|
+
return changed
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Аналіз pipeline перед міграцією: послідовності джоб, легасі lint-джоби,
|
|
365
|
+
* plan-джоба і сервісний каталог. `null` — файл поза скоупом міграції
|
|
366
|
+
* (без paths-тригера, template-розкладка, немає сервісного каталогу).
|
|
367
|
+
* @param {import('yaml').Document} doc розпарсений документ
|
|
368
|
+
* @returns {{ seqs: Array<{ path: Array<string|number>, items: unknown[] }>, allJobs: Array<Record<string, unknown>>, planJob: Record<string, unknown>|undefined, legacy: Array<{ seq: object, job: Record<string, unknown>, path: string|null }>, servicePath: string }|null} аналіз
|
|
369
|
+
*/
|
|
370
|
+
function analyzePipeline(doc) {
|
|
371
|
+
const js = doc.toJS() ?? {}
|
|
372
|
+
const servicePaths = js?.trigger?.paths?.include
|
|
373
|
+
if (!Array.isArray(servicePaths) || servicePaths.length === 0) return null
|
|
374
|
+
|
|
375
|
+
const seqs = collectJobSeqs(doc)
|
|
376
|
+
const allJobs = seqs.flatMap(s => s.items).filter(j => j && typeof j === 'object' && typeof j.job === 'string')
|
|
377
|
+
if (allJobs.length === 0) return null // template-розкладка — не мігруємо
|
|
378
|
+
|
|
379
|
+
const planJob = allJobs.find(j => j.job === 'plan')
|
|
380
|
+
const legacy = seqs.flatMap(seq =>
|
|
381
|
+
seq.items
|
|
382
|
+
.filter(j => j && typeof j === 'object' && typeof j.job === 'string' && j.job !== 'plan')
|
|
383
|
+
.map(j => ({ seq, job: j, found: findLintStep(Array.isArray(j.steps) ? j.steps : []) }))
|
|
384
|
+
.filter(x => x.found?.legacy)
|
|
385
|
+
.map(x => ({ seq: x.seq, job: x.job, path: x.found.path }))
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
// Сервісний каталог: із plan-джоби → з легасі lint-джоби → перший paths.include.
|
|
389
|
+
const planPath = (Array.isArray(planJob?.steps) ? planJob.steps : [])
|
|
390
|
+
.map(s => parseNRulesCmd(stepCmd(s), 'n-rules ci plan')?.path)
|
|
391
|
+
.find(Boolean)
|
|
392
|
+
const servicePath = planPath ?? legacy[0]?.path ?? servicePaths.find(p => typeof p === 'string')
|
|
393
|
+
if (typeof servicePath !== 'string') return null
|
|
394
|
+
|
|
395
|
+
return { seqs, allJobs, planJob, legacy, servicePath }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Мігрує один pipeline-файл до канону. Повертає true, якщо файл змінено.
|
|
400
|
+
* @param {string} absPath абсолютний шлях pipeline-файлу
|
|
401
|
+
* @param {string} cwd корінь consumer-репо
|
|
402
|
+
* @returns {Promise<boolean>} чи були зміни
|
|
403
|
+
*/
|
|
404
|
+
export async function migratePipelineFile(absPath, cwd) {
|
|
405
|
+
const prevText = readFileSync(absPath, 'utf8')
|
|
406
|
+
let doc
|
|
407
|
+
try {
|
|
408
|
+
doc = parseDocument(prevText)
|
|
409
|
+
} catch {
|
|
410
|
+
return false
|
|
411
|
+
}
|
|
412
|
+
const analyzed = analyzePipeline(doc)
|
|
413
|
+
if (!analyzed) return false
|
|
414
|
+
const { seqs, allJobs, planJob, legacy, servicePath } = analyzed
|
|
415
|
+
|
|
416
|
+
const prep = derivePrepSteps(allJobs)
|
|
417
|
+
const domains = await relevantDomains(cwd, servicePath)
|
|
418
|
+
let changed = false
|
|
419
|
+
|
|
420
|
+
if (!planJob) changed = ensurePlanJob(doc, seqs, prep, servicePath) || changed
|
|
421
|
+
const { renames, changed: legacyChanged } = replaceLegacyJobs(doc, legacy, domains, servicePath, prep)
|
|
422
|
+
changed = legacyChanged || changed
|
|
423
|
+
|
|
424
|
+
const lintJobNames = new Set(renames.values().toArray().flat())
|
|
425
|
+
changed = patchDomainLintJobs(doc, seqs, lintJobNames) || changed
|
|
426
|
+
changed = rewireDependents(doc, seqs, renames, lintJobNames) || changed
|
|
427
|
+
|
|
428
|
+
if (!changed) return false
|
|
429
|
+
writeFileSync(absPath, doc.toString())
|
|
430
|
+
return true
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export const patterns = [
|
|
434
|
+
{
|
|
435
|
+
id: 'azure-service-pipeline-canon-migrate',
|
|
436
|
+
test: violations => violations.length > 0,
|
|
437
|
+
async apply(violations, ctx) {
|
|
438
|
+
const files = [...new Set(violations.map(v => v.file).filter(Boolean))]
|
|
439
|
+
const touched = []
|
|
440
|
+
for (const rel of files) {
|
|
441
|
+
const abs = join(ctx.cwd, rel)
|
|
442
|
+
if (!existsSync(abs)) continue
|
|
443
|
+
try {
|
|
444
|
+
if (await migratePipelineFile(abs, ctx.cwd)) touched.push(abs)
|
|
445
|
+
} catch {
|
|
446
|
+
// міграція конкретного файлу не вдалася — лишаємо deny детектору (fail-open до ручного фіксу)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return {
|
|
450
|
+
touchedFiles: touched,
|
|
451
|
+
message: touched.length > 0 ? `мігровано до сервіс-канону: ${touched.length} pipeline(ів)` : null
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
## Rego-gate сервіс-орієнтованих deploy-pipeline (`.azurepipelines/**/*.yml`)
|
|
2
|
+
|
|
3
|
+
Rego-пакет: `azure_pipelines.service_deploy_pipeline`
|
|
4
|
+
|
|
5
|
+
Цільові файли: усі `.azurepipelines/**/*.yml`, крім `templates/**` (walkGlob). Дискримінатор — **`trigger.paths.include`**: файл із paths-фільтром вважається сервісним pipeline і мусить відповідати формі; без нього — концерн мовчить. Кореневий `azure-pipelines.yml` (repo-wide лінт, канон `pipeline_common`) поза глобом — він деплой не гейтить.
|
|
6
|
+
|
|
7
|
+
Канонічна форма per-service pipeline (каталог сервісу — напр. `run/nexus`):
|
|
8
|
+
|
|
9
|
+
1. **Тригер** — `trigger.paths.include` з каталогом сервісу.
|
|
10
|
+
2. **`plan`-джоба** — `checkout: self` із `fetchDepth: 0` → prep (install bun + `bun install --frozen-lockfile`) → крок `bunx n-rules ci plan --path run/<service> --azure` **з `name: plan`** (downstream читає `dependencies.plan.outputs['plan.<domain>']`).
|
|
11
|
+
3. **`lint_<domain>`-джоби** (паралельно) — `dependsOn: plan`, `condition: eq(dependencies.plan.outputs['plan.<domain>'], 'true')` (ключ = домен із `-`→`_`), крок `bunx n-rules lint <domain> --path run/<service> --no-fix` з `--path` ∈ `trigger.paths.include`, prep у кожній джобі (джоби не шарять ФС).
|
|
12
|
+
4. **`run_tests`** — `dependsOn: plan`, `condition` по `plan.any`; тести лише сервісу (python-стек — uv: `curl … astral.sh/uv/install.sh | sh` + `uv sync --locked`).
|
|
13
|
+
5. **Термінальні джоби** (`build_and_push`/`deploy_*`) — dependsOn-ланцюг **транзитивно досягає** `plan` і всі перевірки; джоба з `dependsOn` на умовні lint-джоби має `condition` з `not(canceled())` та `in(dependencies.<j>.result, 'Succeeded', 'Skipped')` — skipped-лінт не блокує деплой, failed — блокує.
|
|
14
|
+
|
|
15
|
+
**Template-розкладка** (efes-стиль `- template:` з параметром-каталогом): законна, rego тоді перевіряє лише видиме у файлі — що параметр template-посилання дорівнює одному з `paths.include`; повнота графа всередині шаблону — відповідальність автора за цим каноном (rego не бачить розгорнутий YAML).
|
|
16
|
+
|
|
17
|
+
**Автоміграція (T0-фікс, `n-rules lint` у fix-режимі):** легасі сервіс-pipeline (inline-джоби, `n-rules lint --path <svc>` без домену) детерміновано переписується до канону — додається job `plan`, легасі lint-джоба замінюється на per-domain `lint_<key>` (домени — по файлах піддерева сервісу, ті самі glob-и, що в `ci plan`), `dependsOn` залежних джоб перешивається, джоби з прямими deps на умовні lint-джоби без власного `condition` отримують Skipped-толерантний канон. Нетривіальний наявний `condition` НЕ перезаписується; template-розкладка не мігрується.
|
|
18
|
+
|
|
19
|
+
Що НЕ перевіряється: добір доменів під вміст сервісу, зміст test/deploy-кроків, uv-prep, шаблони в `templates/**`.
|
|
20
|
+
|
|
21
|
+
Канон-snippet (документаційний приклад): [deploy-service-pipeline.yml.snippet.yml](./template/deploy-service-pipeline.yml.snippet.yml)
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# Форма сервіс-орієнтованого deploy-pipeline (`.azurepipelines/**/*.yml`,
|
|
2
|
+
# крім `templates/**`). Дзеркало ga.service_deploy_workflow для Azure Pipelines.
|
|
3
|
+
#
|
|
4
|
+
# Дискримінатор застосовності — НЕ імʼя файлу, а `trigger.paths.include`:
|
|
5
|
+
# файл із paths-фільтром = сервісний pipeline і мусить відповідати формі;
|
|
6
|
+
# файл без нього (repo-wide) — жодних deny. Кореневий azure-pipelines.yml
|
|
7
|
+
# поза walkGlob узагалі (канон pipeline_common).
|
|
8
|
+
#
|
|
9
|
+
# Канон (azure-pipelines.mdc, сніпет template/deploy-service-pipeline.yml.snippet.yml):
|
|
10
|
+
# plan (checkout fetchDepth:0 + bun install + `bunx n-rules ci plan --path <svc> --azure`,
|
|
11
|
+
# крок з `name: plan`)
|
|
12
|
+
# ├─ lint_<domain> × N (dependsOn: plan; condition по dependencies.plan.outputs['plan.<domain>'];
|
|
13
|
+
# │ `bunx n-rules lint <domain> --path <svc> --no-fix`)
|
|
14
|
+
# ├─ run_tests (dependsOn: plan)
|
|
15
|
+
# └─ build_and_push/deploy (dependsOn-ланцюг досягає plan і всі перевірки;
|
|
16
|
+
# condition: not(canceled()) + 'Skipped'-толерантність)
|
|
17
|
+
#
|
|
18
|
+
# Template-розкладка (`- template:` з параметром-каталогом) законна: rego тоді
|
|
19
|
+
# перевіряє лише видиме у файлі — наявність template-посилання з параметром,
|
|
20
|
+
# що дорівнює одному з paths.include; повна перевірка графа — .mdc-канон.
|
|
21
|
+
package azure_pipelines.service_deploy_pipeline
|
|
22
|
+
|
|
23
|
+
import rego.v1
|
|
24
|
+
|
|
25
|
+
# ── extraction ──────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
service_paths contains p if {
|
|
28
|
+
some p in input.trigger.paths.include
|
|
29
|
+
is_string(p)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
is_service_pipeline if count(service_paths) > 0
|
|
33
|
+
|
|
34
|
+
# Джоби на будь-якій глибині (root jobs / stages→jobs).
|
|
35
|
+
all_jobs contains j if {
|
|
36
|
+
walk(input, [_, j])
|
|
37
|
+
is_object(j)
|
|
38
|
+
is_string(object.get(j, "job", 0))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# `- template:`-посилання з параметрами (efes-стиль modulePath).
|
|
42
|
+
template_refs contains t if {
|
|
43
|
+
walk(input, [_, t])
|
|
44
|
+
is_object(t)
|
|
45
|
+
is_string(object.get(t, "template", 0))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
template_covers_service if {
|
|
49
|
+
some t in template_refs
|
|
50
|
+
some v in object.get(t, "parameters", {})
|
|
51
|
+
v in service_paths
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
plan_jobs contains j if {
|
|
55
|
+
some j in all_jobs
|
|
56
|
+
j.job == "plan"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
plan_runs contains r if {
|
|
60
|
+
some j in plan_jobs
|
|
61
|
+
some r in job_cmds(j)
|
|
62
|
+
contains(r, "n-rules ci plan")
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Lint-джоби (`n-rules lint <domain> … --path …`) з розібраною командою.
|
|
66
|
+
lint_cmds contains cmd if {
|
|
67
|
+
some j in all_jobs
|
|
68
|
+
j.job != "plan"
|
|
69
|
+
some r in job_cmds(j)
|
|
70
|
+
m := regex.find_all_string_submatch_n(`n-rules lint\s+([a-z0-9-]+)\s+[^\n]*--path\s+(\S+)`, r, 1)
|
|
71
|
+
count(m) > 0
|
|
72
|
+
cmd := {"job": j.job, "spec": j, "domain": m[0][1], "path": m[0][2], "run": r}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
check_jobs contains cmd.job if some cmd in lint_cmds
|
|
76
|
+
|
|
77
|
+
# Граф dependsOn і транзитивна досяжність (ланцюг deploy → build → перевірки).
|
|
78
|
+
needs_graph[name] := depends_of(j) if {
|
|
79
|
+
some j in all_jobs
|
|
80
|
+
name := j.job
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
all_needed contains n if {
|
|
84
|
+
some j in all_jobs
|
|
85
|
+
some n in depends_of(j)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
terminal_jobs contains j.job if {
|
|
89
|
+
some j in all_jobs
|
|
90
|
+
j.job != "plan"
|
|
91
|
+
not j.job in check_jobs
|
|
92
|
+
not j.job in all_needed
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
job_by_name[name] := j if {
|
|
96
|
+
some j in all_jobs
|
|
97
|
+
name := j.job
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# ── deny: plan-джоба ────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
deny contains msg if {
|
|
103
|
+
is_service_pipeline
|
|
104
|
+
count(all_jobs) > 0
|
|
105
|
+
count(plan_jobs) == 0
|
|
106
|
+
not template_covers_service
|
|
107
|
+
msg := "service-pipeline: немає job `plan` з кроком `bunx n-rules ci plan --path <svc> --azure` (azure-pipelines.mdc: сервіс-канон)"
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
deny contains msg if {
|
|
111
|
+
is_service_pipeline
|
|
112
|
+
count(all_jobs) == 0
|
|
113
|
+
count(template_refs) > 0
|
|
114
|
+
not template_covers_service
|
|
115
|
+
msg := "service-pipeline: `- template:` без параметра-каталогу з trigger.paths.include — гейт не привʼязаний до сервісу (azure-pipelines.mdc)"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
deny contains msg if {
|
|
119
|
+
msg := "service-pipeline: job `plan` без кроку `bunx n-rules ci plan …` (azure-pipelines.mdc: сервіс-канон)"
|
|
120
|
+
is_service_pipeline
|
|
121
|
+
some j in plan_jobs
|
|
122
|
+
count([r | some r in job_cmds(j); contains(r, "n-rules ci plan")]) == 0
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
deny contains msg if {
|
|
126
|
+
msg := "service-pipeline: `ci plan` без `--azure` — outputs не стануть змінними джоби (azure-pipelines.mdc)"
|
|
127
|
+
is_service_pipeline
|
|
128
|
+
some r in plan_runs
|
|
129
|
+
not contains(r, "--azure")
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
deny contains msg if {
|
|
133
|
+
is_service_pipeline
|
|
134
|
+
some r in plan_runs
|
|
135
|
+
m := regex.find_all_string_submatch_n(`n-rules ci plan\s+--path\s+(\S+)`, r, 1)
|
|
136
|
+
count(m) > 0
|
|
137
|
+
not m[0][1] in service_paths
|
|
138
|
+
msg := sprintf("service-pipeline: `ci plan --path %s` ∉ trigger.paths.include — план рахується не для того каталогу (azure-pipelines.mdc)", [m[0][1]])
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# Outputs адресуються як dependencies.plan.outputs['plan.<key>'] — крок мусить мати name: plan.
|
|
142
|
+
deny contains msg if {
|
|
143
|
+
msg := "service-pipeline: крок `ci plan` без `name: plan` — downstream-condition не знайде outputs (azure-pipelines.mdc)"
|
|
144
|
+
is_service_pipeline
|
|
145
|
+
some j in plan_jobs
|
|
146
|
+
count([s | some s in object.get(j, "steps", []); contains(step_cmd(s), "n-rules ci plan"); s.name == "plan"]) == 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# ── deny: lint-джоби ────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
deny contains msg if {
|
|
152
|
+
is_service_pipeline
|
|
153
|
+
some cmd in lint_cmds
|
|
154
|
+
not "plan" in depends_of(cmd.spec)
|
|
155
|
+
msg := sprintf("%s: lint-джоба мусить мати dependsOn: plan (azure-pipelines.mdc: сервіс-канон)", [cmd.job])
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
deny contains msg if {
|
|
159
|
+
is_service_pipeline
|
|
160
|
+
some cmd in lint_cmds
|
|
161
|
+
cond := object.get(cmd.spec, "condition", "")
|
|
162
|
+
not contains(cond, sprintf("dependencies.plan.outputs['plan.%s']", [replace(cmd.domain, "-", "_")]))
|
|
163
|
+
msg := sprintf("%s: lint-джоба без condition по dependencies.plan.outputs['plan.%s'] — крок не скіпатиметься (azure-pipelines.mdc)", [cmd.job, replace(cmd.domain, "-", "_")])
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
deny contains msg if {
|
|
167
|
+
is_service_pipeline
|
|
168
|
+
some cmd in lint_cmds
|
|
169
|
+
not cmd.path in service_paths
|
|
170
|
+
msg := sprintf("%s: `lint %s --path %s` ∉ trigger.paths.include — лінтиться інший каталог (azure-pipelines.mdc)", [cmd.job, cmd.domain, cmd.path])
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
deny contains msg if {
|
|
174
|
+
is_service_pipeline
|
|
175
|
+
some cmd in lint_cmds
|
|
176
|
+
not contains(cmd.run, "--no-fix")
|
|
177
|
+
msg := sprintf("%s: `n-rules lint` у CI мусить мати `--no-fix` (azure-pipelines.mdc)", [cmd.job])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
deny contains msg if {
|
|
181
|
+
is_service_pipeline
|
|
182
|
+
some cmd in lint_cmds
|
|
183
|
+
not job_has_prep(cmd.spec)
|
|
184
|
+
msg := sprintf("%s: lint-джоба без prep-кроку `bun install --frozen-lockfile` (джоби не шарять ФС) (azure-pipelines.mdc)", [cmd.job])
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# ── deny: fetchDepth для git-дельти ─────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
deny contains msg if {
|
|
190
|
+
msg := "plan: checkout мусить мати fetchDepth: 0 — без історії git-дельта `ci plan` не рахується (azure-pipelines.mdc)"
|
|
191
|
+
is_service_pipeline
|
|
192
|
+
some j in plan_jobs
|
|
193
|
+
count([r | some r in job_cmds(j); contains(r, "n-rules ci plan")]) > 0
|
|
194
|
+
not job_has_full_checkout(j)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
deny contains msg if {
|
|
198
|
+
is_service_pipeline
|
|
199
|
+
some cmd in lint_cmds
|
|
200
|
+
not job_has_full_checkout(cmd.spec)
|
|
201
|
+
msg := sprintf("%s: checkout мусить мати fetchDepth: 0 — без історії `lint --path` не бачить дельти (azure-pipelines.mdc)", [cmd.job])
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
# ── deny: термінальні (deploy) джоби ────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
deny contains msg if {
|
|
207
|
+
is_service_pipeline
|
|
208
|
+
count(check_jobs) > 0
|
|
209
|
+
some name in terminal_jobs
|
|
210
|
+
not "plan" in graph.reachable(needs_graph, {name})
|
|
211
|
+
msg := sprintf("%s: термінальна джоба не досягає `plan` через dependsOn-ланцюг (azure-pipelines.mdc: сервіс-канон)", [name])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
deny contains msg if {
|
|
215
|
+
is_service_pipeline
|
|
216
|
+
some name in terminal_jobs
|
|
217
|
+
some cj in check_jobs
|
|
218
|
+
not cj in graph.reachable(needs_graph, {name})
|
|
219
|
+
msg := sprintf("%s: термінальна джоба не досягає перевірки `%s` через dependsOn-ланцюг — деплой не гейтиться нею (azure-pipelines.mdc)", [name, cj])
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Skipped-лінт (умовний гейт спрацював) не мусить блокувати деплой, fail — мусить:
|
|
223
|
+
# канон `and(not(canceled()), in(dependencies.<j>.result, 'Succeeded', 'Skipped'), …)`.
|
|
224
|
+
deny contains msg if {
|
|
225
|
+
is_service_pipeline
|
|
226
|
+
some name in terminal_jobs
|
|
227
|
+
needed := depends_of(job_by_name[name])
|
|
228
|
+
count([cj | some cj in check_jobs; cj in needed]) > 0
|
|
229
|
+
cond := object.get(job_by_name[name], "condition", "")
|
|
230
|
+
not skip_tolerant(cond)
|
|
231
|
+
msg := sprintf("%s: джоба з dependsOn на умовні lint-джоби мусить мати condition із not(canceled()) і 'Skipped'-толерантністю — інакше skipped-лінт назавжди блокує деплой (azure-pipelines.mdc)", [name])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
# dependsOn: string | array → array.
|
|
237
|
+
depends_of(job) := [n] if {
|
|
238
|
+
n := object.get(job, "dependsOn", [])
|
|
239
|
+
is_string(n)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
depends_of(job) := object.get(job, "dependsOn", []) if is_array(object.get(job, "dependsOn", []))
|
|
243
|
+
|
|
244
|
+
# Команда кроку: `script:` або `bash:`.
|
|
245
|
+
step_cmd(step) := s if {
|
|
246
|
+
s := object.get(step, "script", "")
|
|
247
|
+
s != ""
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
step_cmd(step) := s if {
|
|
251
|
+
object.get(step, "script", "") == ""
|
|
252
|
+
s := object.get(step, "bash", "")
|
|
253
|
+
s != ""
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
step_cmd(step) := "" if {
|
|
257
|
+
object.get(step, "script", "") == ""
|
|
258
|
+
object.get(step, "bash", "") == ""
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
job_cmds(job) := [c |
|
|
262
|
+
some step in object.get(job, "steps", [])
|
|
263
|
+
c := step_cmd(step)
|
|
264
|
+
c != ""
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
job_has_prep(job) if {
|
|
268
|
+
some c in job_cmds(job)
|
|
269
|
+
contains(c, "bun install --frozen-lockfile")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
job_has_full_checkout(job) if {
|
|
273
|
+
some step in object.get(job, "steps", [])
|
|
274
|
+
object.get(step, "checkout", "") != ""
|
|
275
|
+
step.fetchDepth == 0
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
skip_tolerant(cond) if {
|
|
279
|
+
contains(cond, "not(canceled())")
|
|
280
|
+
contains(cond, "'Skipped'")
|
|
281
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Документаційний приклад сервіс-орієнтованого deploy-pipeline (azure-pipelines.mdc).
|
|
2
|
+
# Це НЕ deep-subset-канон (перелік сервісів консюмер-специфічний) — форму
|
|
3
|
+
# перевіряє service_deploy_pipeline.rego по всіх `.azurepipelines/**/*.yml`
|
|
4
|
+
# з trigger.paths.include. Плейсхолдер run/<service> заміни на каталог сервісу.
|
|
5
|
+
trigger:
|
|
6
|
+
branches:
|
|
7
|
+
include:
|
|
8
|
+
- dev
|
|
9
|
+
- main
|
|
10
|
+
paths:
|
|
11
|
+
include:
|
|
12
|
+
- run/<service>
|
|
13
|
+
|
|
14
|
+
pool:
|
|
15
|
+
vmImage: ubuntu-latest
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
- job: plan
|
|
19
|
+
steps:
|
|
20
|
+
- checkout: self
|
|
21
|
+
fetchDepth: 0
|
|
22
|
+
- script: |
|
|
23
|
+
curl -fsSL https://bun.sh/install | bash
|
|
24
|
+
echo "##vso[task.prependpath]$HOME/.bun/bin"
|
|
25
|
+
displayName: Install bun
|
|
26
|
+
- script: bun install --frozen-lockfile
|
|
27
|
+
displayName: Install deps
|
|
28
|
+
- script: bunx n-rules ci plan --path run/<service> --azure
|
|
29
|
+
name: plan # ОБОВʼЯЗКОВО: outputs адресуються як dependencies.plan.outputs['plan.<domain>']
|
|
30
|
+
|
|
31
|
+
- job: lint_js
|
|
32
|
+
dependsOn: plan
|
|
33
|
+
condition: eq(dependencies.plan.outputs['plan.js'], 'true')
|
|
34
|
+
steps:
|
|
35
|
+
- checkout: self
|
|
36
|
+
fetchDepth: 0
|
|
37
|
+
- script: |
|
|
38
|
+
curl -fsSL https://bun.sh/install | bash
|
|
39
|
+
echo "##vso[task.prependpath]$HOME/.bun/bin"
|
|
40
|
+
displayName: Install bun
|
|
41
|
+
- script: bun install --frozen-lockfile
|
|
42
|
+
displayName: Install deps
|
|
43
|
+
- script: bunx n-rules lint js --path run/<service> --no-fix
|
|
44
|
+
displayName: Lint js
|
|
45
|
+
|
|
46
|
+
# …lint_<domain> для кожного домену сервісу (python/docker/k8s/security/…)
|
|
47
|
+
# python-стек: перед тестами ще uv (curl -LsSf https://astral.sh/uv/install.sh | sh + uv sync --locked)
|
|
48
|
+
|
|
49
|
+
- job: run_tests
|
|
50
|
+
dependsOn: plan
|
|
51
|
+
condition: eq(dependencies.plan.outputs['plan.any'], 'true')
|
|
52
|
+
steps:
|
|
53
|
+
- checkout: self
|
|
54
|
+
fetchDepth: 0
|
|
55
|
+
- script: |
|
|
56
|
+
curl -fsSL https://bun.sh/install | bash
|
|
57
|
+
echo "##vso[task.prependpath]$HOME/.bun/bin"
|
|
58
|
+
displayName: Install bun
|
|
59
|
+
- script: bun install --frozen-lockfile
|
|
60
|
+
displayName: Install deps
|
|
61
|
+
- script: bun test run/<service>
|
|
62
|
+
displayName: Tests
|
|
63
|
+
|
|
64
|
+
- job: build_and_push
|
|
65
|
+
dependsOn:
|
|
66
|
+
- plan
|
|
67
|
+
- lint_js
|
|
68
|
+
- run_tests
|
|
69
|
+
# Skipped-лінт (гейт ci plan) НЕ блокує деплой; failed — блокує.
|
|
70
|
+
condition: >-
|
|
71
|
+
and(not(canceled()),
|
|
72
|
+
eq(dependencies.plan.result, 'Succeeded'),
|
|
73
|
+
in(dependencies.lint_js.result, 'Succeeded', 'Skipped'),
|
|
74
|
+
in(dependencies.run_tests.result, 'Succeeded', 'Skipped'))
|
|
75
|
+
steps:
|
|
76
|
+
- checkout: self
|
|
77
|
+
# …кроки build/push/deploy сервісу
|