@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# 变更记录
|
|
2
2
|
|
|
3
|
+
## 0.13.2
|
|
4
|
+
|
|
5
|
+
- Jenkins运维巡检不依赖项目登记和中央配置即可发现Job、检查接入缺项并发送流水线机器人提醒。
|
|
6
|
+
- 节点安装自动生成统一巡检配置;doctor支持每5分钟复查、缺项去重、恢复与复发记录,真实飞书ID核验后才@。
|
|
7
|
+
- 干净检出的待安装依赖交由固定CHECK准备;动态仓库/分支需确认,未受控旧部署任务明确提示。
|
|
8
|
+
- 已核验项目承接固定SHA的受控CHECK,持久记录启动与实际运行编号;未知结果先对账,不盲目重发或续建。
|
|
9
|
+
- 九维机器取证、问题清零、本人Review授权及独立签发继续复用现有Gate执行链;运维巡检不授予构建权限。
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
## [0.13.1] — 2026-10-22
|
|
13
|
+
|
|
14
|
+
- Jenkins安装自动记录实际Node目录;服务器命令别名使用固定Node绝对路径,公共入口及各角色任务自动设置Node/npm的PATH。各节点可用受控WL_NODE_BIN_DIR覆盖本机差异。
|
|
15
|
+
- 接入交付启动器支持WL_NODE_BIN_DIR与现场Node路径,所有安装子进程共用同一套Node/npm;启动前核验执行器原件,脚本被编辑或函数被删除时给出明确恢复提示。
|
|
16
|
+
- 增加精简PATH、自定义Node路径、原件恢复与跨节点环境回归,保留完整性校验及严格构建准入。
|
|
17
|
+
|
|
3
18
|
## [0.13.0] — 2026-10-22
|
|
4
19
|
|
|
5
20
|
- Jenkins服务器公共入口随Gate安装,按实际Job识别检查、构建、中央、签发和维护角色;一个fleet任务批量配置,预览确认、回读、续跑及回退均留记录。
|
package/README.md
CHANGED
package/bin/wl-quality.js
CHANGED
|
@@ -23,6 +23,7 @@ try {
|
|
|
23
23
|
if(o['--confirm'])result=applyJenkinsInstallation({...input,confirm:true,planHash:o['--plan-hash']});
|
|
24
24
|
else if(!o['--preview']&&process.stdin.isTTY&&process.stdout.isTTY&&!o['--json']){const {createInterface}=await import('node:readline/promises');console.log(JSON.stringify(plan,null,2));const prompt=createInterface({input:process.stdin,output:process.stdout});try{if((await prompt.question('输入1确认准备公共入口,Enter取消:')).trim()==='1')result=applyJenkinsInstallation({...input,confirm:true,planHash:plan.planHash});}finally{prompt.close();}}
|
|
25
25
|
console.log(JSON.stringify(result,null,2));
|
|
26
|
-
}else if(command==='jenkins'&&args[0]==='
|
|
26
|
+
}else if(command==='jenkins'&&args[0]==='ops'){args.shift();const {runOpsCli}=await import('../lib/jenkins-ops.js');process.exitCode=await runOpsCli(args);}
|
|
27
|
+
else if(command==='jenkins'&&args[0]==='bootstrap'){args.shift();const {runBootstrapCli}=await import('../lib/jenkins-node-bootstrap.js');process.exitCode=await runBootstrapCli(args);}
|
|
27
28
|
else throw Error('用法:wl-quality init;wl-quality jenkins context|install|bootstrap。');
|
|
28
29
|
}catch(error){console.error('[wl-quality] '+error.message);process.exitCode=2;}
|
package/docs/01-quick-start.md
CHANGED
|
@@ -27,7 +27,7 @@ wl-quality init
|
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
npm exec --yes --ignore-scripts --registry=https://registry.npmjs.org/ \
|
|
30
|
-
--package=@agile-team/wl-skills-gate@0.13.
|
|
30
|
+
--package=@agile-team/wl-skills-gate@0.13.2 -- wl-quality init
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
向导展示差异与实际安装计划,确认后执行;检查异常不能当成零问题。接入后日常由Hook自动检查;正式Review、远端强制检查、群消息与报告权限仍按现场清单验收。
|
package/docs/07-jenkins.md
CHANGED
|
@@ -18,7 +18,7 @@ node('wl-quality-router') { load('/opt/wl-quality/gate-validation/jenkins/entry.
|
|
|
18
18
|
|
|
19
19
|
## 节点自检与失败通知
|
|
20
20
|
|
|
21
|
-
正常接入先运行编号脚本,再由自动创建的`wl-quality-doctor`验环境与机器人。若要在安装Gate前先验证失败通知,使用交付包`验收/环境与飞书验证.Jenkinsfile`:首次默认环境检查;后续在参数化构建中勾选`TEST_NOTIFICATION`可只测消息。自检使用 Python3 标准库;即使 Node 或 Gate
|
|
21
|
+
正常接入先运行编号脚本,再由自动创建的`wl-quality-doctor`验环境与机器人。若要在安装Gate前先验证失败通知,使用交付包`验收/环境与飞书验证.Jenkinsfile`:首次默认环境检查;后续在参数化构建中勾选`TEST_NOTIFICATION`可只测消息。自检使用 Python3 标准库;即使 Node 或 Gate 缺失,也能记录失败并尝试签名通知。安装自动记录实际Node目录,服务器公共入口、命令别名及任务子进程统一使用该目录;其他节点路径不同时,在该节点设置受控`WL_NODE_BIN_DIR`。无需修改受控脚本,不能在自检与构建中各选一套运行时。
|
|
22
22
|
|
|
23
23
|
生成的业务入口与辅助任务均先自检,再执行业务操作。异常处理使用流水线机器人凭据 `wl-feishu-ci-webhook`、`wl-feishu-ci-signing-secret`;凭据只在通知阶段绑定。缺凭据、缺 Python3、网络失败不会把原失败变成成功,也不假报送达。普通代码质量阻断由中央质量卡片通知,避免再发一张环境异常卡片;报告发布失败仍单独报执行异常。
|
|
24
24
|
|
|
@@ -63,7 +63,7 @@ node('wl-quality-router') { load('/opt/wl-quality/gate-validation/jenkins/entry.
|
|
|
63
63
|
|
|
64
64
|
## 当前交付边界
|
|
65
65
|
|
|
66
|
-
本轮以0.13.
|
|
66
|
+
本轮以0.13.2固定正式版本交付。包发布和隔离测试不代表两业务仓库或远端已接通。正式Review后的自动续建、验证构建结果/制品采集、紧凑检查卡片可靠投递已通过隔离贯通测试;真实项目配置、中央服务、TEST任务和飞书身份仍需现场交接/验收。
|
|
67
67
|
|
|
68
68
|
团队操作以交付包根目录的完整或运维接入操作说明为准,本文供维护者排查底层契约。
|
|
69
69
|
|
|
@@ -157,3 +157,16 @@ node('wl-quality-router') { load('/opt/wl-quality/gate-validation/jenkins/entry.
|
|
|
157
157
|
## 验收
|
|
158
158
|
|
|
159
159
|
至少演练:正常完整组合、缺/旧 Review、当前 CI 失败或重试、错 SHA、替换制品/配置、错环境、无授权/重放、冒烟失败与显式恢复。成功与失败都保留原始报告和作业 ID;真实 Jenkins 权限、实际部署/冒烟与群投递另留现场证据。
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
## 运维巡检:项目尚未接入也能先运行
|
|
163
|
+
|
|
164
|
+
服务器统一入口安装后,先创建`wl-quality-fleet`运行“准备公共任务”,再运行`wl-quality-doctor`。它独立读取Jenkins任务、隔离检出明确的目标分支、检查Gate及项目质量配置,并用流水线机器人发送缺项提醒。无需先手工创建空的bindings/fleet/automation文件。
|
|
165
|
+
|
|
166
|
+
`jenkins-ops.json`由安装器首次生成;默认盘点可读任务中的dev、sit、uat、pre、prd、main及test。只读凭据及CI机器人的两项凭据在Jenkins配置一次。动态分支或无法唯一定位的内联脚本,在该文件的`jobMappings`一次确认,不执行项目Jenkinsfile来猜。责任人和已核验飞书身份也在此登记;缺映射会明确提示,仍发送缺项记录。
|
|
167
|
+
|
|
168
|
+
在doctor勾选`ENABLE_MONITOR`运行一次后,每5分钟自动复查。同一缺项不重复刷屏,变化、恢复及复发留存新记录。`RETRY_NOTIFICATION`只用于核对群记录后的明确补发;`RETRY_UNCONFIRMED_CHECK`只用于核对实际队列后的重试,两项均默认关闭,可能重复。
|
|
169
|
+
|
|
170
|
+
项目声明/锁/规则/证据计划未就绪时只告警,不把缺项计为代码风险,也不标记质量通过。已批准且锁定但尚未安装的依赖由CHECK自动准备。项目与登记、实际公共入口、目标分支触发及中央前置配置通过后,巡检可承接该项目固定SHA的CHECK;原有目标分支Hook继续负责后续变更触发。九维证据、实名Review授权、独立签发和BUILD准入仍由各自受控任务执行。未迁移的旧部署/回滚Job保持“未受控”,不能据此声称已经拦截;其Configure权限须限制在批准维护者。
|
|
171
|
+
|
|
172
|
+
本轮JSON及HTML在doctor构建归档可访问,持久记录保存在中央配置目录的`ops-state/`。普通群成员仍需Jenkins报告只读权限;通知送达、真实@、CPS与插件接线均需现场验证。
|
package/docs/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# wl-skills-gate 技术手册索引
|
|
2
2
|
|
|
3
|
-
当前正式版本0.13.
|
|
3
|
+
当前正式版本0.13.2。团队操作以交付包根目录主说明为准,见[16 项目与运维入口](16-project-ci-integration.md);本索引提供工具契约与参数参考。
|
|
4
4
|
|
|
5
5
|
授权声明见 [LICENSE](../LICENSE);公开获取不授予使用、复制、修改或再分发授权,申请入口为 [仓库 Issues](https://github.com/ChenyCHENYU/wl-skills-gate/issues)。
|
|
6
6
|
|
package/lib/jenkins-cluster.js
CHANGED
|
@@ -9,7 +9,8 @@ export const MAINTENANCE_JOBS={node:'wl-quality-doctor',verify:'wl-quality-verif
|
|
|
9
9
|
export const defaultClusterFile=env=>env.WL_QUALITY_CLUSTER_CONFIG||'/srv/wl-quality/config/jenkins-cluster.json';
|
|
10
10
|
export function loadJenkinsCluster(file){
|
|
11
11
|
file=controlledPath(path.resolve(file));const c=readControlledJson(file),base=path.dirname(file);
|
|
12
|
-
assert(c.schemaVersion===1&&c.kind==='wl-quality-jenkins-cluster'&&Object.keys(c).every(k=>['schemaVersion','kind','jenkinsOrigin','registryFiles','configDir','agents','nativeEntry'].includes(k)),'需要有效的集群接入配置');
|
|
12
|
+
assert(c.schemaVersion===1&&c.kind==='wl-quality-jenkins-cluster'&&Object.keys(c).every(k=>['schemaVersion','kind','jenkinsOrigin','registryFiles','configDir','nodeBinDir','agents','nativeEntry'].includes(k)),'需要有效的集群接入配置');
|
|
13
|
+
if(c.nodeBinDir!==undefined){assert(typeof c.nodeBinDir==='string'&&path.isAbsolute(c.nodeBinDir)&&!/[\u0000-\u001f'"`$\\]/.test(c.nodeBinDir),'Node目录须为受控绝对路径');c.nodeBinDir=controlledPath(c.nodeBinDir);}
|
|
13
14
|
const u=new URL(c.jenkinsOrigin);assert(u.protocol==='https:'&&!u.username&&!u.password&&u.pathname==='/'&&!u.search&&!u.hash,'集群入口须为批准HTTPS origin');c.jenkinsOrigin=u.origin+'/';validateNativeEntry(c.nativeEntry);
|
|
14
15
|
assert(Array.isArray(c.registryFiles)&&c.registryFiles.length>0&&c.registryFiles.length<=1000&&new Set(c.registryFiles).size===c.registryFiles.length,'需要无重复的批准登记路径');c.registryFiles=c.registryFiles.map(v=>{assert(typeof v==='string'&&v,'登记路径无效');return controlledPath(path.resolve(base,v));});
|
|
15
16
|
assert(typeof c.configDir==='string'&&c.configDir,'缺少中央配置目录');c.configDir=controlledPath(path.resolve(base,c.configDir));
|
|
@@ -26,5 +27,5 @@ export async function resolveJenkinsContext({role='auto',configFile=defaultClust
|
|
|
26
27
|
assert(entryPointHash(selected.r.nativeEntry)===entryPointHash(c.nativeEntry),'登记与集群公共入口不一致,先更新批准登记');assert(selected.r.bindings.find(b=>Object.values(b.jobs).includes(env.JOB_NAME)).runtimeHash===gateRuntimeHash(),'跨节点包字节不一致,不能路由到另一套规则');
|
|
27
28
|
}
|
|
28
29
|
if(verifyEntry){assert(c.nativeEntry.runtimeHash===gateRuntimeHash(),'Gate运行时与批准公共入口不一致');assert(digest(readFileSync(controlledPath(c.nativeEntry.loaderFile)))===c.nativeEntry.loaderHash,'服务器公共入口被修改,停止执行');}
|
|
29
|
-
return {schemaVersion:1,kind:'wl-quality-jenkins-context',role,agentLabel,gateRoot,registry:registryFile,configDir:c.configDir,clusterFile:c.file,jenkinsOrigin:c.jenkinsOrigin,nativeEntry:c.nativeEntry,entryVerified:verifyEntry,gateVersion:GATE_VERSION,runtimeHash:gateRuntimeHash(),clusterHash:c.configHash,qualityPassed:false,buildAuthorized:false};
|
|
30
|
+
return {schemaVersion:1,kind:'wl-quality-jenkins-context',role,agentLabel,gateRoot,nodeBinDir:c.nodeBinDir||path.dirname(process.execPath),registry:registryFile,configDir:c.configDir,clusterFile:c.file,jenkinsOrigin:c.jenkinsOrigin,nativeEntry:c.nativeEntry,entryVerified:verifyEntry,gateVersion:GATE_VERSION,runtimeHash:gateRuntimeHash(),clusterHash:c.configHash,qualityPassed:false,buildAuthorized:false};
|
|
30
31
|
}
|
|
@@ -6,6 +6,7 @@ import {controlledPath} from './control-store.js';
|
|
|
6
6
|
import {stableStringify} from './util.js';
|
|
7
7
|
import {nativePipelineEntry} from './jenkins-entry-contract.js';
|
|
8
8
|
import {jenkinsBootstrapHelper} from './jenkins-bootstrap.js';
|
|
9
|
+
import {defaultOpsConfiguration} from './jenkins-ops.js';
|
|
9
10
|
const assert=(ok,m)=>{if(!ok)throw Error(m);};
|
|
10
11
|
const quote=v=>"'"+v.replaceAll("'","'\\''")+"'";
|
|
11
12
|
export function planJenkinsInstallation(options={}){
|
|
@@ -15,11 +16,13 @@ export function planJenkinsInstallation(options={}){
|
|
|
15
16
|
const u=new URL(jenkinsOrigin);assert(u.protocol==='https:'&&u.pathname==='/'&&!u.username&&!u.password&&!u.search&&!u.hash,'安装需要批准Jenkins HTTPS origin');assert([centralAgentLabel,reviewSignerAgentLabel].every(v=>/^[A-Za-z0-9_.-]{1,100}$/.test(v||'')),'需要现场实际中央及独立签发节点标签');
|
|
16
17
|
registryFiles||=[path.join(configDir,'jenkins-bindings.json')];assert(Array.isArray(registryFiles)&®istryFiles.length>0&®istryFiles.every(v=>path.isAbsolute(v)),'批准登记路径须明确');
|
|
17
18
|
const clusterFile=path.join(configDir,'jenkins-cluster.json'),loaderFile=path.join(prefix,'jenkins/entry.groovy'),aliasFile=path.join(prefix,'bin/wl-quality');
|
|
18
|
-
const
|
|
19
|
+
const nodeBinDir=controlledPath(path.dirname(process.execPath));assert(!/[\u0000-\u001f'"`$\\]/.test(nodeBinDir),'Node安装路径含不支持的字符');
|
|
20
|
+
const loader=jenkinsBootstrapHelper()+readFileSync(path.join(GATE_ROOT,'templates/ci/jenkins-loader.groovy'),'utf8').replaceAll('__WL_GATE_ROOT__',GATE_ROOT).replaceAll('__WL_CLUSTER_FILE__',clusterFile).replaceAll('__WL_RUNTIME_HASH__',gateRuntimeHash()).replaceAll('__WL_NODE_BIN_DIR__',nodeBinDir);
|
|
19
21
|
const nativeEntry={loaderFile,loaderHash:digest(loader),runtimeHash:gateRuntimeHash()};
|
|
20
|
-
const c={schemaVersion:1,kind:'wl-quality-jenkins-cluster',jenkinsOrigin:u.origin+'/',registryFiles,configDir,agents:{central:centralAgentLabel,review:reviewSignerAgentLabel},nativeEntry};
|
|
21
|
-
const
|
|
22
|
-
const
|
|
22
|
+
const c={schemaVersion:1,kind:'wl-quality-jenkins-cluster',jenkinsOrigin:u.origin+'/',registryFiles,configDir,nodeBinDir,agents:{central:centralAgentLabel,review:reviewSignerAgentLabel},nativeEntry};
|
|
23
|
+
const opsFile=path.join(configDir,'jenkins-ops.json');
|
|
24
|
+
const files=[{file:loaderFile,text:loader,mode:0o644},{file:aliasFile,text:'#!/bin/sh\nset -eu\nPATH='+quote(nodeBinDir)+':"$PATH"\nexport PATH\nexec '+quote(process.execPath)+' '+quote(path.join(GATE_ROOT,'bin/wl-quality.js'))+' "$@"\n',mode:0o755},{file:clusterFile,text:JSON.stringify(c,null,2)+'\n',mode:0o600},{file:opsFile,text:existsSync(opsFile)?readFileSync(controlledPath(opsFile),'utf8'):JSON.stringify(defaultOpsConfiguration(),null,2)+'\n',mode:0o600}].map(f=>{controlledPath(f.file);const before=existsSync(f.file)?readFileSync(f.file,'utf8'):null;return {...f,before,action:before===f.text&&(lstatSync(f.file).mode&0o777)===f.mode?'unchanged':before===null?'create':'update'};});
|
|
25
|
+
const plan={kind:'wl-quality-installation-plan',prefix,configDir,runtimeHash:gateRuntimeHash(),files,nativeEntry,pipelineScript:nativePipelineEntry(nativeEntry),nodeEnvironment:{'PATH+WLQUALITY':path.join(prefix,'bin'),WL_NODE_BIN_DIR:nodeBinDir,WL_QUALITY_CLUSTER_CONFIG:clusterFile},routerLabel:'wl-quality-router'};return {...plan,planHash:digest(stableStringify(plan)),status:'preview'};
|
|
23
26
|
}
|
|
24
27
|
export function applyJenkinsInstallation(options={}){
|
|
25
28
|
const plan=planJenkinsInstallation(options);assert(options.confirm===true&&options.planHash===plan.planHash,'准备执行须绑定当前路径、包版本和差异计划');
|
|
@@ -122,6 +122,7 @@ export function assertBoundCheckout(context, root = process.cwd(), env = process
|
|
|
122
122
|
assert(!source.dirty && sha === git(['rev-parse', `refs/remotes/origin/${b.branch}`], root), '源码未提交、分支错误或本轮checkout已变化');
|
|
123
123
|
assert(git(['remote','get-url','origin'], root) === b.repository, '实际检出仓库与受控登记不一致');
|
|
124
124
|
if (context.mode === 'check') {
|
|
125
|
+
assert(!env.WL_GATE_DISPATCH_ID || env.WL_REVIEW_SHA === sha, '运维承接候选与本轮检出SHA不一致;分支已前进,重新巡检,不检查旧版本冒充当前版本');
|
|
125
126
|
assert(!env.gitlabActionType || env.gitlabAfter, '自动Push事件缺少实际目标SHA');
|
|
126
127
|
assert(!env.gitlabAfter || env.gitlabAfter === sha, '排队期间分支已变化;旧事件不能冒充当前版本');
|
|
127
128
|
} else assert(env.WL_REVIEW_SHA === sha, '受控续建获批SHA与当前目标分支不一致');
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {createHash} from 'node:crypto';
|
|
2
|
+
import {fleetClient,jobPath,fleetAssert as assert} from './jenkins-fleet-client.js';
|
|
3
|
+
import {parseXml} from './xml.js';
|
|
4
|
+
import {isManagedJob,xmlDescendants} from './jenkins-job-config.js';
|
|
5
|
+
|
|
6
|
+
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const gap=(code,message)=>({code,message});
|
|
8
|
+
const nodes=(node,name)=>xmlDescendants(node).filter(v=>v.name===name);
|
|
9
|
+
const child=(node,name)=>{const values=node.children.filter(v=>v.name===name);return values.length===1&&!values[0].children.length?values[0].text.trim():null;};
|
|
10
|
+
const identifier=value=>typeof value==='string'&&/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(value);
|
|
11
|
+
const safeBranch=value=>typeof value==='string'&&value.length>0&&value.length<=250&&!/[\s\u0000-\u001f\u007f$\\~^:?\[\]]/.test(value)&&!value.includes('..')&&!value.includes('@{');
|
|
12
|
+
const safeCredential=value=>typeof value==='string'&&value.length<=250&&!/[\u0000-\u001f\u007f${}]/.test(value);
|
|
13
|
+
function repository(value){
|
|
14
|
+
if(typeof value!=='string'||!value||value.length>2048||/[\s\u0000-\u001f\u007f${}]/.test(value))return null;
|
|
15
|
+
if(/^git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+$/.test(value))return value;
|
|
16
|
+
try{const u=new URL(value);if(!['https:','ssh:'].includes(u.protocol)||!u.hostname||u.password||u.search||u.hash||u.pathname==='/'||!u.pathname)return null;if(u.username&&!(u.protocol==='ssh:'&&u.username==='git'))return null;return value;}catch{return null;}
|
|
17
|
+
}
|
|
18
|
+
const dynamic=value=>{const m=/^(?:\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*))$/.exec(value||'');return m?.[1]||m?.[2]||null;};
|
|
19
|
+
function branches(values,gaps){
|
|
20
|
+
const names=[],variables=new Set();
|
|
21
|
+
for(const value of values){const key=dynamic(value);if(key){variables.add(key);continue;}if(safeBranch(value))names.push(value);else gaps.push(gap('unsupported-branch','分支表达式不是可确认的静态引用。'));}
|
|
22
|
+
if(variables.size>1||variables.size&&names.length){gaps.push(gap('ambiguous-branch','同一SCM存在多个分支来源,须明确项目分支映射。'));return {branches:[]};}
|
|
23
|
+
if(variables.size){gaps.push(gap('dynamic-branch','分支取自运行参数;参数默认值不是本轮实际分支。'));return {branches:[],dynamicBranch:[...variables][0]};}
|
|
24
|
+
return {branches:[...new Set(names)]};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A deliberately small lexical reader, never a Groovy interpreter. Strings are
|
|
28
|
+
// opaque, comments are discarded, and only direct environment literals and
|
|
29
|
+
// named git arguments are considered. Unsupported expressions stay unknown.
|
|
30
|
+
function tokens(script){
|
|
31
|
+
const out=[];let i=0;
|
|
32
|
+
while(i<script.length){
|
|
33
|
+
const c=script[i];
|
|
34
|
+
if(c==='\r'||c==='\n'){out.push({type:'break',value:'\n'});i++;continue;}
|
|
35
|
+
if(/\s/.test(c)){i++;continue;}
|
|
36
|
+
if(script.startsWith('//',i)){const end=script.indexOf('\n',i+2);i=end<0?script.length:end;continue;}
|
|
37
|
+
if(script.startsWith('/*',i)){const end=script.indexOf('*/',i+2);if(end<0)return null;i=end+2;continue;}
|
|
38
|
+
if(script.startsWith('$/',i)){const end=script.indexOf('/$',i+2);if(end<0)return null;out.push({type:'opaque',value:''});i=end+2;continue;}
|
|
39
|
+
if(c==='"'||c==="'"){
|
|
40
|
+
const triple=script.startsWith(c.repeat(3),i),delimiter=triple?c.repeat(3):c,start=i+delimiter.length;let j=start,escaped=false;
|
|
41
|
+
while(j<script.length){if(script[j]==='\\'){escaped=true;j+=2;continue;}if(script.startsWith(delimiter,j))break;j++;}
|
|
42
|
+
if(j>=script.length)return null;
|
|
43
|
+
const value=script.slice(start,j);out.push({type:triple||escaped||value.includes('$')?'opaque':'string',value:triple||escaped||value.includes('$')?'':value});i=j+delimiter.length;continue;
|
|
44
|
+
}
|
|
45
|
+
// Slashy Groovy strings may contain fake calls. Treat the entire literal as
|
|
46
|
+
// opaque rather than accidentally discovering its text as executable code.
|
|
47
|
+
if(c==='/'&&['=','(',':',',','['].includes(out.at(-1)?.value)){
|
|
48
|
+
let j=i+1;while(j<script.length){if(script[j]==='\\'){j+=2;continue;}if(script[j]==='/')break;j++;}if(j>=script.length)return null;out.push({type:'opaque',value:''});i=j+1;continue;
|
|
49
|
+
}
|
|
50
|
+
const word=/^[A-Za-z_][A-Za-z0-9_]*/.exec(script.slice(i));if(word){out.push({type:'word',value:word[0]});i+=word[0].length;continue;}
|
|
51
|
+
out.push({type:'punct',value:c});i++;
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function inlineScm(script,gaps){
|
|
56
|
+
const t=tokens(script);if(!t){gaps.push(gap('unsupported-inline-script','内联脚本语法不能安全静态识别,未执行脚本。'));return [];}
|
|
57
|
+
const literal=new Map(),assigned=new Set();let depth=0,environmentDepth=null;
|
|
58
|
+
for(let i=0;i<t.length;i++){
|
|
59
|
+
if(t[i].value==='{'&&t[i-1]?.value==='environment')environmentDepth=depth+1;
|
|
60
|
+
if(t[i].value==='{')depth++;
|
|
61
|
+
if(environmentDepth===depth&&['GIT_URL','GIT_CREDENTIAL_ID'].includes(t[i].value)&&t[i+1]?.value==='='){
|
|
62
|
+
const key=t[i].value,end=t[i+3],valid=t[i+2]?.type==='string'&&(!end||end.type==='break'||[';','}'].includes(end.value));
|
|
63
|
+
if(assigned.has(key)||!valid)literal.set(key,null);else literal.set(key,t[i+2].value);assigned.add(key);
|
|
64
|
+
}
|
|
65
|
+
if(t[i].value==='}'){if(environmentDepth===depth)environmentDepth=null;depth--;}
|
|
66
|
+
}
|
|
67
|
+
const candidates=[];
|
|
68
|
+
for(let i=0;i<t.length;i++){
|
|
69
|
+
if(t[i].type!=='word'||t[i].value!=='git'||['.','def'].includes(t[i-1]?.value))continue;
|
|
70
|
+
let j=i+1,paren=false;if(t[j]?.value==='('){paren=true;j++;}if(t[j]?.type!=='word'||t[j+1]?.value!==':')continue;
|
|
71
|
+
const args=new Map();let valid=true;
|
|
72
|
+
while(j<t.length){
|
|
73
|
+
while(t[j]?.type==='break')j++;
|
|
74
|
+
if(t[j]?.type!=='word'||t[j+1]?.value!==':'){valid=false;break;}
|
|
75
|
+
const key=t[j].value;j+=2;let value;
|
|
76
|
+
if(t[j]?.type==='string'){value={literal:t[j].value};j++;}
|
|
77
|
+
else if(t[j]?.value==='env'&&t[j+1]?.value==='.'&&t[j+2]?.type==='word'){value={env:t[j+2].value};j+=3;}
|
|
78
|
+
else{valid=false;break;}
|
|
79
|
+
if(args.has(key)){valid=false;break;}args.set(key,value);
|
|
80
|
+
if(t[j]?.value===','){j++;continue;}
|
|
81
|
+
if(paren){while(t[j]?.type==='break')j++;if(t[j]?.value!==')')valid=false;}
|
|
82
|
+
else if(t[j]&&t[j].type!=='break'&&![';','}'].includes(t[j].value))valid=false;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
const url=args.get('url'),credential=args.get('credentialsId'),branch=args.get('branch');
|
|
86
|
+
const resolve=(v,key)=>v?.literal??(v?.env===key?literal.get(key):undefined);
|
|
87
|
+
const repo=repository(resolve(url,'GIT_URL')),cred=resolve(credential,'GIT_CREDENTIAL_ID');
|
|
88
|
+
if(!valid||!repo||credential&&!safeCredential(cred)||!branch||branch.env&&!identifier(branch.env)||branch.literal!==undefined&&!safeBranch(branch.literal)){
|
|
89
|
+
gaps.push(gap('unsupported-inline-checkout','内联git检出不是唯一完整的字面参数,须明确仓库、凭据及分支映射。'));continue;
|
|
90
|
+
}
|
|
91
|
+
const b=branch.env?{branches:[],dynamicBranch:branch.env}:{branches:[branch.literal]};
|
|
92
|
+
candidates.push({repository:repo,credentialId:credential?cred:'',...b});
|
|
93
|
+
if(branch.env)gaps.push(gap('dynamic-branch','分支取自运行参数;参数默认值不是本轮实际分支。'));
|
|
94
|
+
}
|
|
95
|
+
if(!candidates.length)gaps.push(gap('scm-unknown','未发现可安全识别的SCM配置,未执行内联Groovy。'));
|
|
96
|
+
else gaps.push(gap('inline-candidate-unapproved','内联脚本仅静态提取候选,实际运行及接入范围仍须确认。'));
|
|
97
|
+
return candidates;
|
|
98
|
+
}
|
|
99
|
+
function scm(tree,gaps){
|
|
100
|
+
const candidates=[],gitScms=xmlDescendants(tree).filter(n=>n.attrs.class==='hudson.plugins.git.GitSCM'||n.name==='hudson.plugins.git.GitSCM');
|
|
101
|
+
for(const node of gitScms){
|
|
102
|
+
const remotes=nodes(node,'hudson.plugins.git.UserRemoteConfig');
|
|
103
|
+
if(remotes.length!==1){gaps.push(gap('ambiguous-repository','一个SCM包含多个或缺失仓库,未自动选择。'));continue;}
|
|
104
|
+
const remote=remotes[0],repo=repository(child(remote,'url')),credentialId=child(remote,'credentialsId')??'';
|
|
105
|
+
if(!repo||!safeCredential(credentialId)){gaps.push(gap('invalid-repository','仓库地址包含凭据、动态表达式或不支持的格式,未保留地址。'));continue;}
|
|
106
|
+
const specs=nodes(node,'hudson.plugins.git.BranchSpec').map(n=>child(n,'name'));
|
|
107
|
+
if(!specs.length)gaps.push(gap('branch-unknown','SCM未明确声明分支,未使用默认分支推断。'));
|
|
108
|
+
candidates.push({repository:repo,credentialId,...branches(specs,gaps)});
|
|
109
|
+
}
|
|
110
|
+
if(!gitScms.length&&tree.name==='flow-definition'){
|
|
111
|
+
const definitions=tree.children.filter(n=>n.name==='definition');
|
|
112
|
+
if(definitions.length===1&&definitions[0].attrs.class==='org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition'){
|
|
113
|
+
const scripts=definitions[0].children.filter(n=>n.name==='script');
|
|
114
|
+
if(scripts.length===1&&!scripts[0].children.length)candidates.push(...inlineScm(scripts[0].text,gaps));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const unique=[...new Map(candidates.map(v=>[JSON.stringify(v),v])).values()];
|
|
118
|
+
if(new Set(unique.map(v=>v.repository)).size>1){gaps.push(gap('ambiguous-repository','任务含多个仓库候选,须明确业务仓库映射,未自动选择。'));return [];}
|
|
119
|
+
if(!unique.length&&!gaps.length)gaps.push(gap('scm-unknown','未发现可安全识别的Git SCM配置。'));
|
|
120
|
+
return unique;
|
|
121
|
+
}
|
|
122
|
+
function parameterDefaults(tree,candidates,gaps){
|
|
123
|
+
const names=new Set(candidates.map(v=>v.dynamicBranch).filter(Boolean)),values=[];
|
|
124
|
+
for(const name of names){
|
|
125
|
+
const matches=xmlDescendants(tree).filter(n=>/ParameterDefinition$/.test(n.name)&&child(n,'name')===name);
|
|
126
|
+
if(matches.length===1){const value=child(matches[0],'defaultValue');if(value&&safeBranch(value))values.push({name,defaultValue:value});}
|
|
127
|
+
else if(matches.length>1)gaps.push(gap('ambiguous-parameter','分支参数重复声明,未采纳参数默认值。'));
|
|
128
|
+
}
|
|
129
|
+
return values;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// First-use read-only discovery intentionally has no dependency on project
|
|
133
|
+
// bindings, fleet approval files, Gate installation or central automation.
|
|
134
|
+
export async function discoverJenkinsJobs({jenkinsOrigin,inventory={},env=process.env,fetchImpl=fetch}={}){
|
|
135
|
+
assert(inventory&&typeof inventory==='object'&&!Array.isArray(inventory)&&Object.keys(inventory).every(k=>['roots','maxJobs','maxDepth'].includes(k)),'盘点参数无效');
|
|
136
|
+
const options={roots:[''],maxJobs:1000,maxDepth:8,...inventory};
|
|
137
|
+
assert(Array.isArray(options.roots)&&options.roots.length>0&&options.roots.length<=100&&options.roots.every(r=>typeof r==='string'),'需要明确的盘点根目录');
|
|
138
|
+
assert(Number.isInteger(options.maxJobs)&&options.maxJobs>0&&options.maxJobs<=2000&&Number.isInteger(options.maxDepth)&&options.maxDepth>0&&options.maxDepth<=12,'盘点数量或深度预算无效');
|
|
139
|
+
for(const root of options.roots)if(root)jobPath(root);
|
|
140
|
+
const client=fleetClient({origin:jenkinsOrigin,env,fetchImpl}),queue=[...new Set(options.roots)].map(root=>({root,depth:0})),seen=new Set(),visited=new Set(),jobs=[],errors=[];let budget=false;
|
|
141
|
+
while(queue.length&&!budget){
|
|
142
|
+
const {root,depth}=queue.shift();if(visited.has(root))continue;visited.add(root);
|
|
143
|
+
try{
|
|
144
|
+
const data=await client.json((root?jobPath(root):'')+'api/json?tree=jobs[name,_class]');assert(Array.isArray(data.jobs),'盘点接口未返回任务列表');
|
|
145
|
+
for(const item of data.jobs){
|
|
146
|
+
if(!item||typeof item.name!=='string'||item.name.includes('/')){errors.push({root,code:'invalid-job',reason:'盘点返回无效任务名称,未继续该项。'});continue;}
|
|
147
|
+
const job=root?root+'/'+item.name:item.name;try{jobPath(job);}catch{errors.push({root,code:'invalid-job',reason:'盘点返回不支持的任务名称,未继续该项。'});continue;}
|
|
148
|
+
if(seen.has(job))continue;if(seen.size>=options.maxJobs){errors.push({root,code:'job-budget-exceeded',reason:'任务数超过盘点预算,结果不完整。'});budget=true;break;}seen.add(job);
|
|
149
|
+
const type=typeof item._class==='string'&&/^[A-Za-z_$][A-Za-z0-9_.$]{0,249}$/.test(item._class)?item._class:'unknown';
|
|
150
|
+
if(/(?:^|\.)(?:Folder|OrganizationFolder|WorkflowMultiBranchProject)$/.test(type)){
|
|
151
|
+
if(depth>=options.maxDepth)errors.push({job,code:'depth-budget-exceeded',reason:'目录超过盘点深度,结果不完整。'});else queue.push({root:job,depth:depth+1});continue;
|
|
152
|
+
}
|
|
153
|
+
const record={job,type,status:'unreadable',managed:false,configHash:null,scmCandidates:[],discoveryGaps:[]};
|
|
154
|
+
try{
|
|
155
|
+
const xml=await client.config(job);assert(xml!==null,'盘点期间任务已消失:HTTP 404');record.configHash=hash(xml);const tree=parseXml(xml);record.managed=isManagedJob(xml);record.scmCandidates=scm(tree,record.discoveryGaps);const defaults=parameterDefaults(tree,record.scmCandidates,record.discoveryGaps);if(defaults.length)record.parameterDefaults=defaults;record.status=record.discoveryGaps.length?'attention':'read';
|
|
156
|
+
}catch(error){record.discoveryGaps.push(gap('job-unreadable',String(error.message).replace(/https?:\/\/[^\s]+/g,'[URL omitted]').slice(0,300)));}
|
|
157
|
+
record.discoveryGaps=[...new Map(record.discoveryGaps.map(v=>[v.code,v])).values()];jobs.push(record);
|
|
158
|
+
}
|
|
159
|
+
}catch(error){errors.push({root,code:'root-unreadable',reason:String(error.message).replace(/https?:\/\/[^\s]+/g,'[URL omitted]').slice(0,300)});}
|
|
160
|
+
}
|
|
161
|
+
return {kind:'jenkins-ops-inventory',status:errors.length||jobs.some(v=>v.status!=='read')?'attention':'inventoried',jobs,errors,remoteModified:false};
|
|
162
|
+
}
|
|
@@ -14,7 +14,7 @@ export async function planJenkinsBootstrap({configFile,env=process.env,fetchImpl
|
|
|
14
14
|
const entries=[];
|
|
15
15
|
for(const [role,job] of Object.entries(MAINTENANCE_JOBS)){
|
|
16
16
|
const before=await client.config(job);let action='create',reason='';
|
|
17
|
-
if(before!==null){try{const v=jobContract(before);const ownedSchedule=['automation','review'].includes(role)&&v.triggers.length===1&&v.triggers[0].type==='hudson.triggers.TimerTrigger'&&v.triggers[0].fields.spec===
|
|
17
|
+
if(before!==null){try{const v=jobContract(before);const schedule=role==='node'?'H/5 * * * *':'* * * * *';const ownedSchedule=['node','automation','review'].includes(role)&&v.triggers.length===1&&v.triggers[0].type==='hudson.triggers.TimerTrigger'&&v.triggers[0].fields.spec===schedule;assert(v.script===script.trim()&&v.sandbox==='true'&&v.disabled==='false'&&(!v.triggers.length||ownedSchedule),'同名任务存在自定义脚本/触发器或已禁用');action='unchanged';}catch(e){action='blocked';reason=e.message;}}
|
|
18
18
|
// The operator creates this single initial job; never mutate a running self.
|
|
19
19
|
if(role==='fleet'&&before===null){action='blocked';reason='先创建wl-quality-fleet并填入生成的一行入口';}
|
|
20
20
|
entries.push({role,job,action,...(reason?{reason}:{}),beforeHash:before===null?null:digest(before)});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readControlledJson, lockedJson } from './control-store.js';
|
|
4
|
+
import { digest } from './jenkins-integration.js';
|
|
5
|
+
import { stableStringify } from './util.js';
|
|
6
|
+
import { inspectJenkinsReadiness } from './jenkins-readiness.js';
|
|
7
|
+
import { nativePipelineEntry } from './jenkins-entry-contract.js';
|
|
8
|
+
import { jobContract } from './jenkins-job-config.js';
|
|
9
|
+
import { fleetClient, jobPath } from './jenkins-fleet-client.js';
|
|
10
|
+
|
|
11
|
+
const assert = (ok, message) => { if (!ok) throw new Error(message); };
|
|
12
|
+
const hash = value => digest(stableStringify(value));
|
|
13
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
14
|
+
const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
15
|
+
const MAX_ATTEMPTS = 3, REPORT_AGE = 30 * 60 * 1000, NEW_CLAIM_GRACE = 60 * 1000;
|
|
16
|
+
const params = run => {
|
|
17
|
+
const rows = (Array.isArray(run?.actions) ? run.actions : []).flatMap(a => Array.isArray(a?.parameters) ? a.parameters : []);
|
|
18
|
+
const values = name => rows.filter(p => p?.name === name).map(p => p.value);
|
|
19
|
+
const dispatch = values('WL_GATE_DISPATCH_ID'), sha = values('WL_REVIEW_SHA');
|
|
20
|
+
return dispatch.length === 1 && sha.length === 1 ? { dispatchId: dispatch[0], sha: sha[0] } : null;
|
|
21
|
+
};
|
|
22
|
+
const matches = (run, record) => { const p = params(run); return p?.dispatchId === record.dispatchId && p.sha === record.sha; };
|
|
23
|
+
const context = async clusterFile => {
|
|
24
|
+
// The main operations module re-exports this module. Resolve context only
|
|
25
|
+
// inside calls, after module initialization, to avoid a cyclic startup read.
|
|
26
|
+
const { opsContext } = await import('./jenkins-ops.js');
|
|
27
|
+
assert(typeof opsContext === 'function', '运维上下文入口未准备');
|
|
28
|
+
return opsContext(clusterFile);
|
|
29
|
+
};
|
|
30
|
+
const publicRecord = (record, status, extra = {}) => ({ status, job: record.job, sha: record.sha, dispatchId: record.dispatchId, attempt: record.attempt,
|
|
31
|
+
...(record.buildNumber ? { buildNumber: record.buildNumber } : {}), ...extra });
|
|
32
|
+
|
|
33
|
+
function assertState(state) {
|
|
34
|
+
assert(state?.schemaVersion === 1 && state.claims && typeof state.claims === 'object' && !Array.isArray(state.claims), 'CHECK承接台账损坏;先恢复真实台账,不能忽略历史重新触发');
|
|
35
|
+
}
|
|
36
|
+
function assertRecord(record) {
|
|
37
|
+
assert(record && ['claimed', 'started'].includes(record.status) && UUID.test(record.dispatchId || '') && UUID.test(record.runId || '')
|
|
38
|
+
&& /^[a-f0-9]{24}$/.test(record.key || '') && SHA.test(record.sha || '') && Number.isInteger(record.attempt) && record.attempt >= 1 && record.attempt <= MAX_ATTEMPTS
|
|
39
|
+
&& Number.isFinite(Date.parse(record.claimedAt)) && Array.isArray(record.history), '已有CHECK启动记录不完整;需人工核对,不能盲目重新触发');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function currentJob(client, c, project, binding) {
|
|
43
|
+
const xml = await client.config(project.checkJob);
|
|
44
|
+
assert(xml && digest(xml) === project.configHash, 'CHECK实际Job配置已漂移或无法读取,重新巡检后再承接');
|
|
45
|
+
const actual = jobContract(xml);
|
|
46
|
+
const triggers = actual.triggers.filter(v => v.type === 'com.dabsquared.gitlabjenkins.GitLabPushTrigger');
|
|
47
|
+
assert(actual.script === nativePipelineEntry(c.nativeEntry).trim() && actual.sandbox === 'true' && actual.disabled === 'false'
|
|
48
|
+
&& triggers.length === 1 && triggers[0].fields.includeBranchesSpec === binding.branch && triggers[0].fields.triggerOnPush === 'true'
|
|
49
|
+
&& triggers[0].fields.branchFilterType === 'NameBasedFilter', 'CHECK实际受控入口、运行状态或目标分支触发已变化,重新巡检');
|
|
50
|
+
for (const name of ['WL_REVIEW_SHA', 'WL_GATE_DISPATCH_ID']) assert(actual.parameters.filter(p => p.name === name && p.type === 'hudson.model.StringParameterDefinition').length === 1, 'CHECK缺少唯一受控候选SHA或启动追踪参数,不能承接');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function fetchRun(client, job, number) {
|
|
54
|
+
const response = await client.request('GET', `${jobPath(job)}${number}/api/json?tree=number,building,result,actions[parameters[name,value]],artifacts[fileName,relativePath]`);
|
|
55
|
+
if (response.status !== 200) return null;
|
|
56
|
+
try { const run = JSON.parse(response.body); return String(run.number) === String(number) ? run : null; } catch { return null; }
|
|
57
|
+
}
|
|
58
|
+
async function reconcileUnknown(client, record) {
|
|
59
|
+
try {
|
|
60
|
+
const data = await client.json(`${jobPath(record.job)}api/json?tree=builds[number,building,result,actions[parameters[name,value]],artifacts[fileName,relativePath]]{0,25}`);
|
|
61
|
+
if (!Array.isArray(data.builds) || data.builds.length > 25) return { status: 'unknown', reason: '未取得完整的近期CHECK记录;不自动重复启动' };
|
|
62
|
+
const found = data.builds.filter(run => matches(run, record));
|
|
63
|
+
if (found.length !== 1 || !/^[1-9][0-9]*$/.test(String(found[0]?.number || ''))) return { status: 'unknown', reason: found.length > 1 ? '同一追踪ID匹配多次运行,需人工核对;不自动重发' : '近期运行尚未找到唯一追踪ID与候选SHA;不自动重发', notFound: found.length === 0 };
|
|
64
|
+
record.status = 'started'; record.buildNumber = String(found[0].number); record.reconciledAt = new Date().toISOString();
|
|
65
|
+
return { status: 'found', run: found[0] };
|
|
66
|
+
} catch { return { status: 'unknown', reason: 'Jenkins运行对账未完成;保留本轮启动记录,不自动重发' }; }
|
|
67
|
+
}
|
|
68
|
+
async function failureKind(client, record, run, project) {
|
|
69
|
+
// Only explicit artifact metadata can establish absence. A broken/read-denied
|
|
70
|
+
// JSON response is not evidence that no quality check ran.
|
|
71
|
+
if (!Array.isArray(run.artifacts)) return 'unverified';
|
|
72
|
+
const reports = run.artifacts.filter(a => a.relativePath === 'quality-reports/quality-summary.json');
|
|
73
|
+
if (!reports.length) return 'execution';
|
|
74
|
+
if (reports.length !== 1) return 'unverified';
|
|
75
|
+
try {
|
|
76
|
+
const response = await client.request('GET', `${jobPath(record.job)}${record.buildNumber}/artifact/quality-reports/quality-summary.json`);
|
|
77
|
+
if (response.status !== 200) return 'unverified';
|
|
78
|
+
const summary = JSON.parse(response.body);
|
|
79
|
+
if (summary?.run?.commitSha !== record.sha || summary.run.project !== project.project || summary.run.stage !== 'ci' || !Array.isArray(summary.findings) || !Array.isArray(summary.checks)) return 'unverified';
|
|
80
|
+
if (summary.findings.length) return 'quality';
|
|
81
|
+
return summary.checks.length && summary.checks.every(check => check.status === 'passed') ? 'execution' : 'unverified';
|
|
82
|
+
} catch { return 'unverified'; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function claimOpsCheck({ clusterFile, runId, key, env = process.env, fetchImpl = fetch, now = Date.now(), retryUnknown = false, retryKnownFailure = false }) {
|
|
86
|
+
const { c, bindings } = await context(clusterFile);
|
|
87
|
+
assert(UUID.test(runId || '') && /^[a-f0-9]{24}$/.test(key || ''), '需要本轮巡检身份');
|
|
88
|
+
const instant = typeof now === 'function' ? now() : now;
|
|
89
|
+
assert(Number.isFinite(instant), 'CHECK承接时间无效');
|
|
90
|
+
const report = readControlledJson(path.join(c.configDir, 'ops-state/runs', runId, 'report.json'));
|
|
91
|
+
const age = instant - Date.parse(report.checkedAt);
|
|
92
|
+
assert(report.kind === 'jenkins-ops-report' && report.schemaVersion === 1 && report.runId === runId && report.clusterHash === c.configHash
|
|
93
|
+
&& report.jenkinsOrigin === c.jenkinsOrigin && Number.isFinite(age) && age >= 0 && age <= REPORT_AGE, '巡检报告不属于当前集群或已超过30分钟,重新巡检后再承接');
|
|
94
|
+
const projects = Array.isArray(report.projects) ? report.projects.filter(p => p.key === key) : [], p = projects.length === 1 ? projects[0] : null;
|
|
95
|
+
const bound = bindings.find(b => b.binding.id === p?.bindingId && b.registryFile === p?.registryFile);
|
|
96
|
+
const awaitingReconcile = p?.status === 'attention' && p.gaps?.length === 1 && p.gaps[0].id === 'CHECK-DISPATCH-UNKNOWN';
|
|
97
|
+
assert(Array.isArray(report.globalGaps) && report.globalGaps.length === 0 && p && Array.isArray(p.gaps) && (p.status === 'ready' && !p.gaps.length || awaitingReconcile)
|
|
98
|
+
&& p.pinsVerified && p.entryVerified && bound && p.checkJob === bound.binding.jobs.check && p.job === p.checkJob && hash(bound.binding) === p.bindingHash
|
|
99
|
+
&& SHA.test(p.sha || '') && /^[a-f0-9]{64}$/.test(p.configHash || ''), '仅本轮实际核验且已登记的CHECK可承接;不会启动旧部署或BUILD');
|
|
100
|
+
assert(inspectJenkinsReadiness({ clusterFile: c.file }).status === 'handover-files-ready', '承接前工程配置变化,重新巡检');
|
|
101
|
+
const client = fleetClient({ origin: c.jenkinsOrigin, env, fetchImpl });
|
|
102
|
+
await currentJob(client, c, p, bound.binding);
|
|
103
|
+
return lockedJson(path.join(c.configDir, 'ops-state/checks.json'), { schemaVersion: 1, kind: 'jenkins-ops-checks', claims: {} }, async state => {
|
|
104
|
+
assertState(state);
|
|
105
|
+
const id = hash({ job: p.checkJob, sha: p.sha, binding: p.bindingHash }), old = state.claims[id];
|
|
106
|
+
let canRetry = false;
|
|
107
|
+
if (old) {
|
|
108
|
+
assertRecord(old);
|
|
109
|
+
assert(old.job === p.checkJob && old.sha === p.sha && old.bindingHash === p.bindingHash, 'CHECK历史记录与当前批准范围不一致');
|
|
110
|
+
let run;
|
|
111
|
+
if (old.status === 'claimed') {
|
|
112
|
+
const reconciliation = await reconcileUnknown(client, old);
|
|
113
|
+
if (reconciliation.status === 'found') run = reconciliation.run;
|
|
114
|
+
else {
|
|
115
|
+
const claimAge = instant - Date.parse(old.claimedAt);
|
|
116
|
+
if (!(retryUnknown === true && reconciliation.notFound && claimAge >= NEW_CLAIM_GRACE)) return publicRecord(old, 'unknown', { reason: retryUnknown && claimAge < NEW_CLAIM_GRACE ? '新启动仍在一分钟观察窗口内,先等待并核对队列;不重复启动' : reconciliation.reason });
|
|
117
|
+
canRetry = true;
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
try { run = await fetchRun(client, old.job, old.buildNumber); } catch {}
|
|
121
|
+
if (!run || !matches(run, old)) return publicRecord(old, 'unknown', { reason: '实际CHECK运行未唯一核验追踪ID与候选SHA;不自动重复启动' });
|
|
122
|
+
}
|
|
123
|
+
if (run) {
|
|
124
|
+
if (run.building === true || run.result === 'SUCCESS') return publicRecord(old, 'already-started', { building: run.building === true, result: run.result || null });
|
|
125
|
+
if (run.building !== false || !['FAILURE', 'ABORTED'].includes(run.result)) return publicRecord(old, 'unknown', { reason: '实际运行尚未取得明确的终止结果;不自动重复启动' });
|
|
126
|
+
const kind = await failureKind(client, old, run, p);
|
|
127
|
+
old.lastResult = run.result; old.failureKind = kind;
|
|
128
|
+
if (kind === 'quality') return publicRecord(old, 'quality-failed', { reason: '本候选已有真实代码问题;修复并提交新版本后再检查,不重复刷同一候选' });
|
|
129
|
+
if (awaitingReconcile && retryUnknown !== true) return publicRecord(old, 'failed-needs-confirmation', { reason: '已核对到终止失败;本轮仅允许对账,确认修复执行环境后显式重试' });
|
|
130
|
+
if (kind !== 'execution' && retryKnownFailure !== true) return publicRecord(old, 'failed-needs-confirmation', { reason: '已确认运行失败,但未能排除质量失败;查看原件后显式重试' });
|
|
131
|
+
canRetry = true;
|
|
132
|
+
}
|
|
133
|
+
if (canRetry && old.attempt >= MAX_ATTEMPTS) return publicRecord(old, 'retry-limit', { reason: '同一候选已达到三次启动上限;查看实际CHECK原件,修复后由责任人明确处理' });
|
|
134
|
+
} else assert(!awaitingReconcile, '本轮只有待对账缺项,却没有实际启动台账;恢复台账后再处理');
|
|
135
|
+
const record = { status: 'claimed', runId, key, job: p.checkJob, sha: p.sha, bindingHash: p.bindingHash, dispatchId: randomUUID(), claimedAt: new Date(instant).toISOString(), attempt: old ? old.attempt + 1 : 1,
|
|
136
|
+
history: old ? [...old.history, Object.fromEntries(Object.entries(old).filter(([k]) => k !== 'history'))] : [] };
|
|
137
|
+
state.claims[id] = record;
|
|
138
|
+
// lockedJson fsyncs this record before this function returns. The caller
|
|
139
|
+
// may submit exactly this dispatchId/SHA, then acknowledge the actual run.
|
|
140
|
+
return publicRecord(record, 'claimed');
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function acknowledgeOpsCheck({ clusterFile, runId, key, buildNumber, dispatchId }) {
|
|
145
|
+
const { c } = await context(clusterFile);
|
|
146
|
+
assert(UUID.test(runId || '') && /^[a-f0-9]{24}$/.test(key || ''), '需要本轮巡检身份');
|
|
147
|
+
assert(/^[1-9][0-9]*$/.test(String(buildNumber || '')), 'CHECK启动须记录实际Jenkins运行编号');
|
|
148
|
+
return lockedJson(path.join(c.configDir, 'ops-state/checks.json'), { schemaVersion: 1, kind: 'jenkins-ops-checks', claims: {} }, state => {
|
|
149
|
+
assertState(state);
|
|
150
|
+
const rows = Object.values(state.claims).filter(v => v.runId === runId && v.key === key && ['claimed', 'started'].includes(v.status));
|
|
151
|
+
assert(rows.length === 1, '不存在唯一待确认的本轮CHECK启动记录');
|
|
152
|
+
const record = rows[0]; assertRecord(record);
|
|
153
|
+
if (dispatchId !== undefined) assert(UUID.test(dispatchId || '') && dispatchId === record.dispatchId, '迟到或其他dispatchId不能确认当前CHECK启动;先对账实际运行');
|
|
154
|
+
assert(record.status !== 'started' || record.buildNumber === String(buildNumber), '本轮已确认其他实际运行编号,不能覆盖');
|
|
155
|
+
record.status = 'started'; record.buildNumber = String(buildNumber);
|
|
156
|
+
return publicRecord(record, 'started');
|
|
157
|
+
});
|
|
158
|
+
}
|