@agile-team/wl-skills-gate 0.13.0 → 0.13.2
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 +15 -0
- package/README.md +1 -1
- package/bin/wl-quality.js +2 -1
- package/docs/01-quick-start.md +1 -1
- package/docs/07-jenkins.md +15 -2
- package/docs/README.md +1 -1
- package/lib/jenkins-cluster.js +3 -2
- package/lib/jenkins-installation.js +7 -4
- package/lib/jenkins-integration.js +1 -0
- package/lib/jenkins-inventory.js +162 -0
- package/lib/jenkins-node-bootstrap.js +1 -1
- package/lib/jenkins-ops-checks.js +158 -0
- package/lib/jenkins-ops-notification.js +125 -0
- package/lib/jenkins-ops.js +129 -0
- package/npm-shrinkwrap.json +7 -7
- package/package.json +1 -1
- package/scripts/lib-tests/jenkins-full-chain.test.mjs +4 -1
- package/scripts/lib-tests/jenkins-inventory.test.mjs +96 -0
- package/scripts/lib-tests/jenkins-native.test.mjs +3 -0
- package/scripts/lib-tests/jenkins-ops-checks.test.mjs +171 -0
- package/scripts/lib-tests/jenkins-ops-native.test.mjs +248 -0
- package/scripts/lib-tests/jenkins-ops-notification.test.mjs +88 -0
- package/scripts/lib-tests/jenkins-ops.test.mjs +238 -0
- package/templates/ci/check-project.config.json +1 -1
- package/templates/ci/jenkins-bootstrap.py +1 -1
- package/templates/ci/jenkins-loader.groovy +2 -0
- package/templates/ci/jenkins-native.groovy +141 -1
- package/templates/onboarding/backend/quality-tools.package.json +1 -1
- package/templates/quality/rules.lock.json +1 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {test} from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {existsSync, readFileSync, rmSync} from 'node:fs';
|
|
5
|
+
import {randomUUID, createHmac} from 'node:crypto';
|
|
6
|
+
import {fixture, git, put, commit, bind} from '../test-support/jenkins-fixture.mjs';
|
|
7
|
+
import {planJenkinsInstallation, applyJenkinsInstallation} from '../../lib/jenkins-installation.js';
|
|
8
|
+
import {inventoryOps, inspectOpsProject, finishOps, opsReportHtml} from '../../lib/jenkins-ops.js';
|
|
9
|
+
import {nativePipelineEntry} from '../../lib/jenkins-entry-contract.js';
|
|
10
|
+
import {renderFleetJob} from '../../lib/jenkins-job-config.js';
|
|
11
|
+
|
|
12
|
+
const repository = 'https://gitlab.example.invalid/produce/wl-produce.git';
|
|
13
|
+
const readEnv = {WL_JENKINS_READ_USER: 'readonly', WL_JENKINS_READ_TOKEN: 'fixture-read-secret'};
|
|
14
|
+
const noticeEnv = {
|
|
15
|
+
WL_FEISHU_CI_WEBHOOK: 'https://open.feishu.cn/open-apis/bot/v2/hook/test-fixture-ci',
|
|
16
|
+
WL_FEISHU_CI_SECRET: 'fixture-ci-signing-secret',
|
|
17
|
+
JOB_NAME: 'wl-quality-doctor', BUILD_NUMBER: '21',
|
|
18
|
+
BUILD_URL: 'https://jenkins.example.invalid/job/wl-quality-doctor/21/',
|
|
19
|
+
};
|
|
20
|
+
const escapeXml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
21
|
+
const scmXml = (branch = 'test') => `<flow-definition><properties/><definition class="org.jenkinsci.plugins.workflow.cps.CpsScmFlowDefinition"><scm class="hudson.plugins.git.GitSCM"><userRemoteConfigs><hudson.plugins.git.UserRemoteConfig><url>${repository}</url><credentialsId>scm-readonly</credentialsId></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs><branches><hudson.plugins.git.BranchSpec><name>${branch}</name></hudson.plugins.git.BranchSpec></branches></scm><scriptPath>Jenkinsfile</scriptPath></definition><disabled>false</disabled></flow-definition>`;
|
|
22
|
+
const inlineXml = script => `<flow-definition><properties/><definition class="org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition"><script>${escapeXml(script)}</script><sandbox>true</sandbox></definition><disabled>false</disabled></flow-definition>`;
|
|
23
|
+
|
|
24
|
+
function install(t) {
|
|
25
|
+
const f = fixture(t);
|
|
26
|
+
git(f.root, 'remote', 'set-url', 'origin', repository);
|
|
27
|
+
const options = {prefix: path.join(f.base, 'install'), configDir: path.join(f.base, 'config'), registryFiles: [f.registryFile], jenkinsOrigin: 'https://jenkins.example.invalid/', centralAgentLabel: 'central', reviewSignerAgentLabel: 'signer'};
|
|
28
|
+
const plan = planJenkinsInstallation(options);
|
|
29
|
+
const installed = applyJenkinsInstallation({...options, confirm: true, planHash: plan.planHash});
|
|
30
|
+
const configFile = path.join(options.configDir, 'jenkins-ops.json');
|
|
31
|
+
const config = JSON.parse(readFileSync(configFile));
|
|
32
|
+
config.people = {manager: {name: '技术经理', feishu: {idType: 'open_id', id: 'ou_fixture_manager', verified: true}}};
|
|
33
|
+
config.owners = {'wl-produce': {technicalManager: 'manager'}};
|
|
34
|
+
put(options.configDir, 'jenkins-ops.json', config);
|
|
35
|
+
return {...f, installed, clusterFile: installed.clusterFile, configDir: options.configDir};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function jenkinsProvider(jobs) {
|
|
39
|
+
const calls = [];
|
|
40
|
+
const fetchImpl = async (url, options = {}) => {
|
|
41
|
+
const endpoint = new URL(url);
|
|
42
|
+
calls.push({url: endpoint.href, method: options.method || 'GET'});
|
|
43
|
+
assert.equal(endpoint.origin, 'https://jenkins.example.invalid');
|
|
44
|
+
assert.equal(options.method, 'GET', 'ops inventory must never write Jenkins');
|
|
45
|
+
assert.equal(options.headers.Authorization, 'Basic ' + Buffer.from('readonly:fixture-read-secret').toString('base64'));
|
|
46
|
+
const segments = endpoint.pathname.split('/').filter(Boolean), job = [];
|
|
47
|
+
let index = 0;
|
|
48
|
+
while (segments[index] === 'job') { job.push(decodeURIComponent(segments[index + 1])); index += 2; }
|
|
49
|
+
const suffix = segments.slice(index).join('/'), full = job.join('/');
|
|
50
|
+
if (suffix === 'api/json') {
|
|
51
|
+
const children = new Map();
|
|
52
|
+
for (const name of jobs.keys()) {
|
|
53
|
+
if (full && !name.startsWith(full + '/')) continue;
|
|
54
|
+
const parts = (full ? name.slice(full.length + 1) : name).split('/');
|
|
55
|
+
children.set(parts[0], {name: parts[0], _class: parts.length > 1 ? 'com.cloudbees.hudson.plugins.folder.Folder' : 'org.jenkinsci.plugins.workflow.job.WorkflowJob'});
|
|
56
|
+
}
|
|
57
|
+
return new Response(JSON.stringify({jobs: [...children.values()]}));
|
|
58
|
+
}
|
|
59
|
+
if (suffix === 'config.xml') return jobs.has(full) ? new Response(jobs.get(full)) : new Response('', {status: 404});
|
|
60
|
+
throw Error('unexpected read route: ' + endpoint.pathname);
|
|
61
|
+
};
|
|
62
|
+
return {calls, fetchImpl};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const freshInventory = inventory => ({...inventory, runId: randomUUID()});
|
|
66
|
+
const failureResult = (f, inventory) => inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: inventory.targets[0].key, failure: 'checkout-not-completed'});
|
|
67
|
+
const inspection = (f, inventory) => inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: inventory.targets[0].key, root: f.root});
|
|
68
|
+
|
|
69
|
+
async function controlledProject(t) {
|
|
70
|
+
const f = install(t);
|
|
71
|
+
put(f.root, 'pom.xml', '<project><modelVersion>4.0.0</modelVersion><artifactId>fixture</artifactId><profiles><profile><id>wl-quality-audit</id></profile><profile><id>wl-quality-unit</id></profile></profiles></project>');
|
|
72
|
+
put(f.root, '.wl-skills-bd/capabilities.json', {schemaVersion: 1, fixture: true});
|
|
73
|
+
const policy = JSON.parse(readFileSync(path.join(f.root, 'quality/policy.yml')));
|
|
74
|
+
policy.checks[0].profiles.push('release'); policy.releaseRequires = ['FIXTURE'];
|
|
75
|
+
put(f.root, 'quality/policy.yml', policy);
|
|
76
|
+
commit(f.root);
|
|
77
|
+
const seed = JSON.parse(readFileSync(f.seedFile)); seed.binding.repository = repository;
|
|
78
|
+
put(f.base, 'seed.json', seed);
|
|
79
|
+
await bind(f);
|
|
80
|
+
const registry = JSON.parse(readFileSync(f.registryFile)); registry.nativeEntry = f.installed.nativeEntry;
|
|
81
|
+
put(f.base, 'bindings.json', registry);
|
|
82
|
+
// Readiness performs file-level handover checks. These controlled fixture
|
|
83
|
+
// documents are not used to pretend a real SSO, Review signer or build ran.
|
|
84
|
+
for (const name of ['automation-config', 'governance-config', 'control-config', 'review-publish-config']) {
|
|
85
|
+
put(f.configDir, name + '.json', {schemaVersion: 1, projects: {'wl-produce': {}}, sources: [{id: 'fixture'}], dataDir: f.base, publicKeyFile: 'approved-fixture.pem', identity: {provider: 'fixture'}, authorizationIssuer: 'fixture', reviewPrivateKeyEnv: 'FIXTURE_PRIVATE_FILE'});
|
|
86
|
+
}
|
|
87
|
+
put(f.configDir, 'jenkins-fleet.json', {schemaVersion: 1, kind: 'wl-gate-jenkins-fleet', jenkinsOrigin: 'https://jenkins.example.invalid/', dataDir: path.join(f.base, 'fleet'), registryFiles: [f.registryFile], automationConfigFile: path.join(f.configDir, 'automation-config.json'), selection: {projects: ['wl-produce'], branches: ['test']}, approvers: ['fixture']});
|
|
88
|
+
const binding = registry.bindings[0];
|
|
89
|
+
const xml = renderFleetJob({script: nativePipelineEntry(f.installed.nativeEntry), binding, mode: 'check', secret: 'fixture-gitlab-hook-secret'});
|
|
90
|
+
const jobs = new Map([[binding.jobs.check, xml]]), provider = jenkinsProvider(jobs);
|
|
91
|
+
const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
92
|
+
return {...f, registry, binding, jobs, provider, inventory};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readyReport(f, inventory = f.inventory) {
|
|
96
|
+
const result = inspection(f, inventory);
|
|
97
|
+
assert.equal(result.status, 'ready', JSON.stringify(result.gaps));
|
|
98
|
+
assert.equal(result.pinsVerified, true);
|
|
99
|
+
const report = await finishOps({clusterFile: f.clusterFile, inventory, results: [result], send: false});
|
|
100
|
+
assert.equal(report.status, 'ready', JSON.stringify(report.globalGaps));
|
|
101
|
+
return report;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test('无登记/中央配置也能真实盘点、发现项目缺Gate并通过CI机器人持久提醒;不变不刷屏', async t => {
|
|
105
|
+
const f = install(t);
|
|
106
|
+
rmSync(path.join(f.root, 'quality'), {recursive: true});
|
|
107
|
+
commit(f.root);
|
|
108
|
+
const provider = jenkinsProvider(new Map([['生产/后端-test', scmXml()]]));
|
|
109
|
+
const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
110
|
+
assert(!existsSync(f.registryFile));
|
|
111
|
+
assert(!existsSync(path.join(f.configDir, 'automation-config.json')));
|
|
112
|
+
assert.equal(inventory.targets.length, 1);
|
|
113
|
+
assert.equal(inventory.targets[0].checkoutAllowed, true);
|
|
114
|
+
assert.equal(inventory.remoteModified, false);
|
|
115
|
+
assert.equal(provider.calls.every(call => call.method === 'GET'), true);
|
|
116
|
+
const result = inspection(f, inventory);
|
|
117
|
+
assert.equal(result.status, 'attention');
|
|
118
|
+
assert(result.gaps.some(g => g.id === 'PROJECT-GATE'));
|
|
119
|
+
assert(result.gaps.some(g => g.id === 'UNCONTROLLED-JOB'));
|
|
120
|
+
const sent = [];
|
|
121
|
+
const send = async (url, options) => {
|
|
122
|
+
assert.equal(url, noticeEnv.WL_FEISHU_CI_WEBHOOK);
|
|
123
|
+
const card = JSON.parse(options.body);
|
|
124
|
+
assert.equal(card.sign, createHmac('sha256', `${card.timestamp}\n${noticeEnv.WL_FEISHU_CI_SECRET}`).update('').digest('base64'));
|
|
125
|
+
sent.push(card);
|
|
126
|
+
return new Response(JSON.stringify({code: 0}));
|
|
127
|
+
};
|
|
128
|
+
const report = await finishOps({clusterFile: f.clusterFile, inventory, results: [result], env: noticeEnv, send: true, fetchImpl: send});
|
|
129
|
+
assert.equal(report.notification.status, 'sent');
|
|
130
|
+
assert.equal(report.notification.mentionMappingComplete, true);
|
|
131
|
+
assert.deepEqual(report.notification.mentions, ['ou_fixture_manager']);
|
|
132
|
+
assert.equal(report.qualityPassed, false);
|
|
133
|
+
assert.equal(report.buildAuthorized, false);
|
|
134
|
+
assert.match(JSON.stringify(sent[0]), /PROJECT-GATE|quality\/workspace|尚未完整接入Gate/);
|
|
135
|
+
const state = JSON.parse(readFileSync(path.join(f.configDir, 'ops-state/state.json')));
|
|
136
|
+
assert(Object.values(state.notices).some(item => item.status === 'sent' && item.runId === inventory.runId));
|
|
137
|
+
assert(existsSync(path.join(f.configDir, 'ops-state/runs', inventory.runId, 'report.json')));
|
|
138
|
+
const next = freshInventory(inventory);
|
|
139
|
+
const again = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [inspection(f, next)], env: noticeEnv, send: true, fetchImpl: send});
|
|
140
|
+
assert.equal(again.notification.status, 'unchanged');
|
|
141
|
+
assert.equal(sent.length, 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('飞书送达未知保留待核对;下一轮相同缺项不会自动补发', async t => {
|
|
145
|
+
const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
|
|
146
|
+
const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
147
|
+
let sends = 0;
|
|
148
|
+
const unknown = async () => { sends++; throw Error('fixture timeout after server may have accepted'); };
|
|
149
|
+
const first = await finishOps({clusterFile: f.clusterFile, inventory, results: [failureResult(f, inventory)], env: noticeEnv, send: true, fetchImpl: unknown});
|
|
150
|
+
assert.equal(first.notification.status, 'unknown');
|
|
151
|
+
const next = freshInventory(inventory);
|
|
152
|
+
const second = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [failureResult(f, next)], env: noticeEnv, send: true, fetchImpl: unknown});
|
|
153
|
+
assert.equal(second.notification.status, 'unknown');
|
|
154
|
+
assert.equal(sends, 1);
|
|
155
|
+
const state = JSON.parse(readFileSync(path.join(f.configDir, 'ops-state/state.json')));
|
|
156
|
+
assert(Object.values(state.notices).some(item => item.status === 'unknown'));
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('动态参数不猜固定分支,多仓库不擅选;feature分支不进入目标检查', async t => {
|
|
160
|
+
const f = install(t);
|
|
161
|
+
const dynamic = `pipeline { agent any\nenvironment {\nGIT_URL = '${repository}'\nGIT_CREDENTIAL_ID = 'scm-readonly'\n}\nstages { stage('checkout') { steps { git branch: env.TAG, credentialsId: env.GIT_CREDENTIAL_ID, url: env.GIT_URL } } } }`;
|
|
162
|
+
const multi = scmXml().replace('</userRemoteConfigs>', '<hudson.plugins.git.UserRemoteConfig><url>https://gitlab.example.invalid/common/shared.git</url><credentialsId>scm-readonly</credentialsId></hudson.plugins.git.UserRemoteConfig></userRemoteConfigs>');
|
|
163
|
+
const provider = jenkinsProvider(new Map([['参数分支', inlineXml(dynamic)], ['多仓库', multi], ['自己的分支', scmXml('feature/order-fix')]]));
|
|
164
|
+
const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
165
|
+
assert.equal(inventory.jobsDiscovered, 3);
|
|
166
|
+
assert.equal(inventory.targets.length, 2);
|
|
167
|
+
assert(!inventory.targets.some(target => target.job === '自己的分支'));
|
|
168
|
+
for (const target of inventory.targets) {
|
|
169
|
+
assert.equal(target.checkoutAllowed, false);
|
|
170
|
+
assert.equal(target.branch, null);
|
|
171
|
+
assert(target.gaps.some(g => g.id === 'SCM-LOCATION'));
|
|
172
|
+
const result = inspectOpsProject({clusterFile: f.clusterFile, inventory, targetKey: target.key, failure: 'checkout-not-completed'});
|
|
173
|
+
assert.equal(result.status, 'attention');
|
|
174
|
+
assert.equal(result.sha, undefined);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('检出失败、缺失结果、重复结果和旧runId均保留待处理;不复用历史绿灯', async t => {
|
|
179
|
+
const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
|
|
180
|
+
const first = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
181
|
+
const failed = failureResult(f, first);
|
|
182
|
+
assert(failed.gaps.some(g => g.id === 'CHECKOUT'));
|
|
183
|
+
assert.equal(failed.sha, undefined);
|
|
184
|
+
assert.equal(failed.qualityPassed, false);
|
|
185
|
+
for (const mode of ['failed', 'missing', 'old', 'duplicate']) {
|
|
186
|
+
const inventory = freshInventory(first), current = {...failed, runId: inventory.runId};
|
|
187
|
+
const results = mode === 'missing' ? [] : mode === 'old' ? [failed] : mode === 'duplicate' ? [current, current] : [current];
|
|
188
|
+
const report = await finishOps({clusterFile: f.clusterFile, inventory, results, send: false});
|
|
189
|
+
assert.equal(report.projects[0].status, 'attention', mode);
|
|
190
|
+
assert(report.projects[0].gaps.some(g => g.id === (mode === 'failed' ? 'CHECKOUT' : 'INSPECTION-MISSING')), mode);
|
|
191
|
+
assert.equal(report.buildAuthorized, false, mode);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('干净检出没有node_modules是待固定依赖准备,不误报未声明Gate', async t => {
|
|
196
|
+
const f = install(t), provider = jenkinsProvider(new Map([['后端-test', scmXml()]]));
|
|
197
|
+
assert(!existsSync(path.join(f.root, 'quality-tools/node_modules')));
|
|
198
|
+
const inventory = await inventoryOps({clusterFile: f.clusterFile, env: readEnv, fetchImpl: provider.fetchImpl});
|
|
199
|
+
const report = inspection(f, inventory);
|
|
200
|
+
assert.equal(report.projectKind, 'backend');
|
|
201
|
+
assert(!report.gaps.some(g => g.id === 'PROJECT-GATE' || /^(?:INSTALLED-|ENGINES-)/.test(g.id)));
|
|
202
|
+
assert(report.dependenciesPreparedByCheck.includes('INSTALLED-GATE'));
|
|
203
|
+
assert(report.dependenciesPreparedByCheck.includes('INSTALLED-BD'));
|
|
204
|
+
assert(!report.gaps.some(g => g.id === 'PIN-GATE'));
|
|
205
|
+
assert.equal(report.qualityPassed, false);
|
|
206
|
+
assert.equal(report.buildAuthorized, false);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('真实接入前置恢复后发送恢复提醒;同一缺项复发不能被历史sent吞掉', async t => {
|
|
210
|
+
const f = await controlledProject(t), first = f.inventory;
|
|
211
|
+
assert.equal(first.errors.length, 0);
|
|
212
|
+
let calls = 0;
|
|
213
|
+
const send = async () => { calls++; return new Response(JSON.stringify({code: 0})); };
|
|
214
|
+
const before = await finishOps({clusterFile: f.clusterFile, inventory: first, results: [failureResult(f, first)], env: noticeEnv, send: true, fetchImpl: send});
|
|
215
|
+
assert.equal(before.status, 'attention');
|
|
216
|
+
assert.equal(before.notification.status, 'sent');
|
|
217
|
+
const next = freshInventory(first), result = inspection(f, next);
|
|
218
|
+
assert.equal(result.status, 'ready', JSON.stringify(result.gaps));
|
|
219
|
+
assert.equal(result.pinsVerified, true);
|
|
220
|
+
const after = await finishOps({clusterFile: f.clusterFile, inventory: next, results: [result], env: noticeEnv, send: true, fetchImpl: send});
|
|
221
|
+
assert.equal(after.status, 'ready');
|
|
222
|
+
assert.equal(after.recovered, true);
|
|
223
|
+
assert.equal(after.notification.status, 'sent');
|
|
224
|
+
assert.equal(after.qualityPassed, false);
|
|
225
|
+
assert.equal(after.buildAuthorized, false);
|
|
226
|
+
const recurring = freshInventory(first);
|
|
227
|
+
const recurrence = await finishOps({clusterFile: f.clusterFile, inventory: recurring, results: [failureResult(f, recurring)], env: noticeEnv, send: true, fetchImpl: send});
|
|
228
|
+
assert.equal(recurrence.notification.status, 'sent', 'a new recurrence after recovery needs a fresh reminder');
|
|
229
|
+
assert.equal(calls, 3);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('HTML把项目、Job和缺项当不可信文本转义,不形成脚本与属性注入', () => {
|
|
233
|
+
const html = opsReportHtml({checkedAt: '<script>alert(1)</script>', status: 'attention', globalGaps: [{message: '<img src=x onerror=alert(1)>', action: '& " < >'}], projects: [{key: 'x" onclick="alert(1)', project: '<script>project()</script>', branch: '<b>test</b>', jobs: ['<img src=x>'], gaps: [{id: '<svg>', message: '<iframe>', action: '<script>fix()</script>'}]}]});
|
|
234
|
+
assert.doesNotMatch(html, /<script>|<img\b|<iframe>|<svg>|<b>test/);
|
|
235
|
+
assert.match(html, /<script>project\(\)<\/script>/);
|
|
236
|
+
assert.match(html, /id="x" onclick="alert\(1\)"/);
|
|
237
|
+
assert.match(html, /& " < >/);
|
|
238
|
+
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"kind": "wl-quality-ci-check",
|
|
4
4
|
"project": "REPLACE_WITH_ACTUAL_PROJECT",
|
|
5
|
-
"gateVersion": "0.13.
|
|
5
|
+
"gateVersion": "0.13.2",
|
|
6
6
|
"workspaceHash": "REPLACE_WITH_APPROVED_WORKSPACE_SHA256",
|
|
7
7
|
"lockHash": "REPLACE_WITH_APPROVED_LOCK_SHA256",
|
|
8
8
|
"policyHash": "REPLACE_WITH_APPROVED_POLICY_SHA256",
|
|
@@ -55,7 +55,7 @@ def preflight(env):
|
|
|
55
55
|
valid_root = root.is_absolute() and root.is_dir()
|
|
56
56
|
check("gate-root", valid_root, "固定 Gate 目录可读取" if valid_root else "固定 Gate 目录不存在或不可读取",
|
|
57
57
|
"在该Jenkins节点运行本包的接入Jenkins脚本;使用安装回执中的 gateHome。包内入口为 bin/wl-skills-gate.js。")
|
|
58
|
-
expected = env.get("WL_BOOTSTRAP_GATE_VERSION", "0.13.
|
|
58
|
+
expected = env.get("WL_BOOTSTRAP_GATE_VERSION", "0.13.2")
|
|
59
59
|
try:
|
|
60
60
|
package = json.loads((root / "package.json").read_text())
|
|
61
61
|
correct = package.get("name") == "@agile-team/wl-skills-gate" and package.get("version") == expected
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Installed by Gate outside every project workspace. No shared Git repository.
|
|
3
3
|
def adapter = null
|
|
4
4
|
def target = null
|
|
5
|
+
withEnv(["WL_NODE_BIN_DIR=${env.WL_NODE_BIN_DIR ?: '__WL_NODE_BIN_DIR__'}", "PATH+WLNODE=${env.WL_NODE_BIN_DIR ?: '__WL_NODE_BIN_DIR__'}"]) {
|
|
5
6
|
try {
|
|
6
7
|
wlGatePreflight(mode: 'node', gateRoot: '__WL_GATE_ROOT__', runtimeHash: '__WL_RUNTIME_HASH__')
|
|
7
8
|
def routeDir = "wl-quality-route-${java.util.UUID.randomUUID().toString()}"
|
|
@@ -17,6 +18,7 @@ try {
|
|
|
17
18
|
wlGateFailureNotice()
|
|
18
19
|
throw failure
|
|
19
20
|
}
|
|
21
|
+
}
|
|
20
22
|
def bootstrap = this
|
|
21
23
|
// Return first: the caller releases the router executor before allocating a
|
|
22
24
|
// business/signing Agent. This also works with a single executor on a node.
|
|
@@ -1,4 +1,137 @@
|
|
|
1
1
|
// Package-owned Scripted Pipeline; no separately published shared library.
|
|
2
|
+
def wlOpsRun(Map cfg) {
|
|
3
|
+
def publicRoot = "${pwd()}/wl-ops-public"
|
|
4
|
+
dir('wl-ops-public') { deleteDir(); dir('projects') { writeFile file: '.keep', text: '' } }
|
|
5
|
+
def inventory = null
|
|
6
|
+
def outcome = 2
|
|
7
|
+
withEnv(["WL_QUALITY_CLI=${cfg.gateRoot}/bin/wl-quality.js", "WL_OPS_CLUSTER=${cfg.clusterFile}", "WL_OPS_PUBLIC=${publicRoot}"]) {
|
|
8
|
+
try {
|
|
9
|
+
stage('发现项目与流水线') {
|
|
10
|
+
withCredentials([usernamePassword(credentialsId: 'wl-jenkins-ci-readonly', usernameVariable: 'WL_JENKINS_READ_USER', passwordVariable: 'WL_JENKINS_READ_TOKEN')]) {
|
|
11
|
+
sh '''#!/bin/sh
|
|
12
|
+
set -eu
|
|
13
|
+
set +x
|
|
14
|
+
node "$WL_QUALITY_CLI" jenkins ops inventory --cluster "$WL_OPS_CLUSTER" --out "$WL_OPS_PUBLIC/inventory.json"
|
|
15
|
+
'''
|
|
16
|
+
}
|
|
17
|
+
inventory = new groovy.json.JsonSlurperClassic().parseText(readFile('wl-ops-public/inventory.json'))
|
|
18
|
+
echo "巡检发现 ${inventory.jobsDiscovered} 个任务;目标 ${inventory.targets.size()} 项。"
|
|
19
|
+
}
|
|
20
|
+
stage('检查项目接入条件') {
|
|
21
|
+
for (def target in inventory.targets) {
|
|
22
|
+
withEnv(["WL_OPS_KEY=${target.key}", "WL_OPS_RESULT=${publicRoot}/projects/${target.key}.json"]) {
|
|
23
|
+
def checkoutFailed = false
|
|
24
|
+
if (target.checkoutAllowed == true) {
|
|
25
|
+
dir("wl-ops-checkouts/${target.key}") {
|
|
26
|
+
deleteDir()
|
|
27
|
+
try {
|
|
28
|
+
try {
|
|
29
|
+
timeout(time: 5, unit: 'MINUTES') {
|
|
30
|
+
checkout(changelog: false, poll: false, scm: [
|
|
31
|
+
$class: 'GitSCM', branches: [[name: "origin/${target.branch}"]],
|
|
32
|
+
userRemoteConfigs: [[name: 'origin', url: target.repository, credentialsId: target.credentialId,
|
|
33
|
+
refspec: "+refs/heads/${target.branch}:refs/remotes/origin/${target.branch}"]],
|
|
34
|
+
extensions: [[$class: 'CloneOption', shallow: true, depth: 1, noTags: true, honorRefspec: true]]
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
} catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException interrupted) { throw interrupted }
|
|
38
|
+
catch (Exception checkoutFailure) { checkoutFailed = true; echo '源码检出未完成;仅记录缺项,继续检查其余项目。' }
|
|
39
|
+
if (!checkoutFailed) {
|
|
40
|
+
sh(returnStatus: true, script: '''#!/bin/sh
|
|
41
|
+
set -eu
|
|
42
|
+
node "$WL_QUALITY_CLI" jenkins ops inspect --cluster "$WL_OPS_CLUSTER" --inventory "$WL_OPS_PUBLIC/inventory.json" --target-key "$WL_OPS_KEY" --root "$PWD" --out "$WL_OPS_RESULT"
|
|
43
|
+
''')
|
|
44
|
+
}
|
|
45
|
+
} finally { deleteDir() }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (checkoutFailed || target.checkoutAllowed != true) {
|
|
49
|
+
sh(returnStatus: true, script: '''#!/bin/sh
|
|
50
|
+
set -eu
|
|
51
|
+
node "$WL_QUALITY_CLI" jenkins ops inspect --cluster "$WL_OPS_CLUSTER" --inventory "$WL_OPS_PUBLIC/inventory.json" --target-key "$WL_OPS_KEY" --failure checkout-not-completed --out "$WL_OPS_RESULT"
|
|
52
|
+
''')
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
try {
|
|
59
|
+
if (fileExists('wl-ops-public/inventory.json')) {
|
|
60
|
+
stage('记录缺项与飞书提醒') {
|
|
61
|
+
try { withEnv(["WL_OPS_RETRY_NOTICE=${params.RETRY_NOTIFICATION == true ? '1' : '0'}"]) { withCredentials([
|
|
62
|
+
string(credentialsId: 'wl-feishu-ci-webhook', variable: 'WL_FEISHU_CI_WEBHOOK'),
|
|
63
|
+
string(credentialsId: 'wl-feishu-ci-signing-secret', variable: 'WL_FEISHU_CI_SECRET')
|
|
64
|
+
]) {
|
|
65
|
+
outcome = sh(returnStatus: true, script: '''#!/bin/sh
|
|
66
|
+
set -eu
|
|
67
|
+
set +x
|
|
68
|
+
set -- jenkins ops finish --cluster "$WL_OPS_CLUSTER" --inventory "$WL_OPS_PUBLIC/inventory.json" --results "$WL_OPS_PUBLIC/projects" --out "$WL_OPS_PUBLIC/summary.json" --send
|
|
69
|
+
if [ "$WL_OPS_RETRY_NOTICE" = '1' ]; then set -- "$@" --retry-notification; fi
|
|
70
|
+
node "$WL_QUALITY_CLI" "$@"
|
|
71
|
+
''')
|
|
72
|
+
} } } catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException interrupted) { throw interrupted }
|
|
73
|
+
catch (Exception noticeFailure) {
|
|
74
|
+
echo '通知阶段未完成;请核对流水线机器人凭据。本次仍生成巡检原件,不登记消息送达。'
|
|
75
|
+
if (!fileExists('wl-ops-public/summary.json')) sh(returnStatus: true, script: '''#!/bin/sh
|
|
76
|
+
set -eu
|
|
77
|
+
node "$WL_QUALITY_CLI" jenkins ops finish --cluster "$WL_OPS_CLUSTER" --inventory "$WL_OPS_PUBLIC/inventory.json" --results "$WL_OPS_PUBLIC/projects" --out "$WL_OPS_PUBLIC/summary.json"
|
|
78
|
+
''')
|
|
79
|
+
outcome = 2
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} finally { archiveArtifacts artifacts: 'wl-ops-public/*.json,wl-ops-public/*.html,wl-ops-public/projects/*.json', allowEmptyArchive: true }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (fileExists('wl-ops-public/summary.json')) {
|
|
87
|
+
def summary = new groovy.json.JsonSlurperClassic().parseText(readFile('wl-ops-public/summary.json'))
|
|
88
|
+
if (!summary.globalGaps) wlOpsContinue(cfg, summary)
|
|
89
|
+
}
|
|
90
|
+
if (outcome != 0) { currentBuild.result = 'UNSTABLE'; echo '运维巡检有待处理项或消息未确认送达;见本次归档,修复后下一轮自动复查。本结果不授予构建准入。' }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Only package-verified CHECK jobs can be resumed; never arbitrary legacy deploy jobs.
|
|
94
|
+
def wlOpsContinue(Map cfg, Map summary) {
|
|
95
|
+
try {
|
|
96
|
+
stage('承接已就绪项目的九维检查') {
|
|
97
|
+
for (def project in summary.projects.findAll { it.checkJob && (it.status == 'ready' || it.gaps.every { g -> g.id == 'CHECK-DISPATCH-UNKNOWN' }) }) {
|
|
98
|
+
withEnv(["WL_QUALITY_CLI=${cfg.gateRoot}/bin/wl-quality.js", "WL_OPS_CLUSTER=${cfg.clusterFile}", "WL_OPS_RUN=${summary.runId}", "WL_OPS_KEY=${project.key}", "WL_OPS_CLAIM=wl-ops-public/claim-${project.key}.json", "WL_OPS_ACK=wl-ops-public/ack-${project.key}.json", "WL_OPS_RETRY_CHECK=${params.RETRY_UNCONFIRMED_CHECK == true ? '1' : '0'}"]) {
|
|
99
|
+
def claimCode = 2
|
|
100
|
+
withCredentials([usernamePassword(credentialsId: 'wl-jenkins-ci-readonly', usernameVariable: 'WL_JENKINS_READ_USER', passwordVariable: 'WL_JENKINS_READ_TOKEN')]) {
|
|
101
|
+
claimCode = sh(returnStatus: true, script: '''#!/bin/sh
|
|
102
|
+
set -eu
|
|
103
|
+
set +x
|
|
104
|
+
set -- jenkins ops claim-check --cluster "$WL_OPS_CLUSTER" --run-id "$WL_OPS_RUN" --target-key "$WL_OPS_KEY" --out "$WL_OPS_CLAIM"
|
|
105
|
+
if [ "$WL_OPS_RETRY_CHECK" = '1' ]; then set -- "$@" --retry-unknown; fi
|
|
106
|
+
node "$WL_QUALITY_CLI" "$@"
|
|
107
|
+
''')
|
|
108
|
+
}
|
|
109
|
+
if (claimCode == 0 && fileExists("wl-ops-public/claim-${project.key}.json")) {
|
|
110
|
+
def claim = new groovy.json.JsonSlurperClassic().parseText(readFile("wl-ops-public/claim-${project.key}.json"))
|
|
111
|
+
if (claim.status == 'claimed') {
|
|
112
|
+
try {
|
|
113
|
+
def started = build(job: claim.job, wait: false, waitForStart: true, propagate: false,
|
|
114
|
+
parameters: [string(name: 'WL_REVIEW_SHA', value: claim.sha), string(name: 'WL_GATE_DISPATCH_ID', value: claim.dispatchId)])
|
|
115
|
+
withEnv(["WL_OPS_BUILD=${started.number}", "WL_OPS_DISPATCH=${claim.dispatchId}"]) {
|
|
116
|
+
sh '''#!/bin/sh
|
|
117
|
+
set -eu
|
|
118
|
+
node "$WL_QUALITY_CLI" jenkins ops ack-check --cluster "$WL_OPS_CLUSTER" --run-id "$WL_OPS_RUN" --target-key "$WL_OPS_KEY" --build-number "$WL_OPS_BUILD" --dispatch-id "$WL_OPS_DISPATCH" --out "$WL_OPS_ACK"
|
|
119
|
+
'''
|
|
120
|
+
}
|
|
121
|
+
echo "CHECK已启动:${claim.job} #${started.number};正式检查及Review结论以该运行原件为准。"
|
|
122
|
+
} catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException interrupted) { throw interrupted }
|
|
123
|
+
catch (Exception dispatchFailure) { currentBuild.result = 'UNSTABLE'; echo 'CHECK启动未确认,已保留本轮记录;下轮提醒核对,不自动重复启动。' }
|
|
124
|
+
} else {
|
|
125
|
+
if (claim.status != 'already-started') currentBuild.result = 'UNSTABLE'
|
|
126
|
+
echo "CHECK承接:${claim.status};${claim.reason ?: '已有实际启动记录,未重复启动'}"
|
|
127
|
+
}
|
|
128
|
+
} else { currentBuild.result = 'UNSTABLE'; echo '本项目CHECK承接核验未通过,未启动;按本轮控制台缺项修复,其余项目继续。' }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} finally { archiveArtifacts artifacts: 'wl-ops-public/claim-*.json,wl-ops-public/ack-*.json', allowEmptyArchive: true }
|
|
133
|
+
}
|
|
134
|
+
|
|
2
135
|
def wlFleetRun(Map cfg, String action, String batch = '', String planHash = '') {
|
|
3
136
|
def code = 2
|
|
4
137
|
withEnv(["WL_APPROVED_GATE_CLI=${cfg.gateRoot}/bin/wl-skills-gate.js",
|
|
@@ -53,11 +186,16 @@ def wlPrepareJobs(Map cfg) {
|
|
|
53
186
|
}
|
|
54
187
|
def call(Map cfg, def bootstrap) {
|
|
55
188
|
node(cfg.agentLabel) {
|
|
189
|
+
withEnv(["WL_NODE_BIN_DIR=${env.WL_NODE_BIN_DIR ?: cfg.nodeBinDir ?: '/usr/bin'}", "PATH+WLNODE=${env.WL_NODE_BIN_DIR ?: cfg.nodeBinDir ?: '/usr/bin'}"]) {
|
|
56
190
|
timeout(time: cfg.role == 'fleet' ? 120 : 30, unit: 'MINUTES') {
|
|
57
191
|
try {
|
|
58
192
|
if (cfg.role != 'job') {
|
|
59
193
|
def options = [disableConcurrentBuilds()]
|
|
60
|
-
if (cfg.role == 'node')
|
|
194
|
+
if (cfg.role == 'node') {
|
|
195
|
+
def enabled = params.ENABLE_MONITOR == true
|
|
196
|
+
options.add(parameters([booleanParam(name: 'TEST_NOTIFICATION', defaultValue: false, description: '仅测试流水线机器人签名推送'), booleanParam(name: 'ENABLE_MONITOR', defaultValue: enabled, description: '启用每5分钟发现项目、检查接入缺项并提醒;不代替业务九维检查'), booleanParam(name: 'RETRY_NOTIFICATION', defaultValue: false, description: '核对群记录后补发本轮缺项通知,可能重复;仅本次'), booleanParam(name: 'RETRY_UNCONFIRMED_CHECK', defaultValue: false, description: '核对队列和运行记录后重试未确认CHECK,可能重复;仅本次')]))
|
|
197
|
+
options.add(pipelineTriggers(enabled ? [cron('H/5 * * * *')] : []))
|
|
198
|
+
}
|
|
61
199
|
if (cfg.role == 'fleet') options.add(parameters([
|
|
62
200
|
choice(name: 'ACTION', choices: ['准备公共任务', '盘点', '预览', '接入', '续跑', '核验', '回退'], description: '首轮准备公共任务,之后按批准登记批量接入'),
|
|
63
201
|
string(name: 'BATCH', defaultValue: '', description: '续跑、核验、回退填写原批次ID')]))
|
|
@@ -74,6 +212,7 @@ def call(Map cfg, def bootstrap) {
|
|
|
74
212
|
load("${cfg.gateRoot}/templates/ci/jenkins-gate.groovy").call(cfg)
|
|
75
213
|
} else if (cfg.role == 'node') {
|
|
76
214
|
if (params.TEST_NOTIFICATION) stage('飞书连通测试') { bootstrap.wlGateNotificationTest() }
|
|
215
|
+
else wlOpsRun(cfg)
|
|
77
216
|
} else if (cfg.role == 'verify') {
|
|
78
217
|
stage('核验Gate接线') {
|
|
79
218
|
withCredentials([usernamePassword(credentialsId: 'wl-jenkins-ci-readonly', usernameVariable: 'WL_JENKINS_READ_USER', passwordVariable: 'WL_JENKINS_READ_TOKEN')]) {
|
|
@@ -140,6 +279,7 @@ node "$WL_APPROVED_GATE_CLI" automation --config "$WL_CENTRAL_CONFIG/automation-
|
|
|
140
279
|
archiveArtifacts artifacts: '.wl-gate-bootstrap/**,wl-fleet-public/*,wl-bootstrap-public/*.json,review-delivery/**', allowEmptyArchive: true
|
|
141
280
|
}
|
|
142
281
|
}
|
|
282
|
+
}
|
|
143
283
|
}
|
|
144
284
|
}
|
|
145
285
|
return this
|