Groovy Script Console:如何找出Jenkins中所有授信失效的项目

本文来源于在一次Jenkins培训当中,有用户提问:我怎么知道自己的授信失效了。于是通过Jenkins提供的Groovy Script Console能力,编写脚本找出Jenkins中所有授信失效的项目。

背景

公司自建的CI平台基于Jenkins的,为了方便解答用户使用时碰到的问题,团队直接建立内部沟通群直接对接到用户。时间一长,发现用户经常遇到无法clone代码的问题,日志类似如下。

2020-04-10 at 12.28 A

仔细看会发现是由Authentication failed导致的,往往是由于用户密码修改后,没有同步在CI平台及时更新从而授信失效导致的。

现象

由于公司网络安全管控,每个员工均要在90天周期更换一次密码。而更新密码之后,又没有手动同步到CI平台。最直接的现象就是,点进项目编辑页,会明显看到stderr: remote: HTTP Basic: Access deniederror信息
2020-04-10 at 12.27 A

能想到的几种解决方案

当沟通群一旦有人丢出来拉不到代码的问题,绝大部分都是由这个问题导致的,而我们也群里也不厌其烦帮用户解释了一次又一次,只要更新一下对应的授信密码就行。回过头来想,如何尽可能减少重复的事情发生呢?

  • 90天周期更新密码时,自动同步更新平台授信:可以彻底解决,但目前无法实现
  • 主动找出平台中哪些授信是已经失效了的,主动通知用户去更新:由被动用户找变成了主动告知用户,当前最切实的方案
  • 建议用户使用SSH方式的授信,密码过期了也没事:对于新项目可以主动引导用户使用SSH类型授信,由于历史问题,平台上已有项目绝大部分是UserPassword类型授信

以上看来,如果可以每天定时轮询平台上所有的项目的授信,找出已失效的授信的项目,发送提醒邮件给用户及时更新密码,并建议用户换成SSH授信,是目前最切实际的做法。那么问题第一步就是,如何找到失效授信的任务?
个人觉得有两种思路:

  • 遍历所有上一次运行失败的项目,查看日志中是否包含Access denied关键字
  • 遍历所有项目,模拟调用check url请求,判断当前项目是否授信失效

对于第2点,需要补充说明:当在项目编辑页面,所看到的error提示,是Jenkins调用了check url 请求返回的校验结果。
Request如下:

1
2
3
Request URL: http://{host}:8081/job/test-fail-job-cased-by-credential-failed/descriptorByName/hudson.plugins.git.UserRemoteConfig/checkUrl
value: http://git.midea.com/paas/customize-xxl-job.git
credentialsId: c5axxx10-xxxx-4192-xxxx-25b6xx8e00b

Response如下:

1
2
<div class=error><img src='/static/18ef55c1/images/none.gif' height=16 width=1>Failed to connect to repository : Command &quot;/usr/local/bin/git ls-remote -h http://git.midea.com/paas/customize-xxl-job.git HEAD&quot; returned status code 128:<br>stdout: <br>stderr: remote: HTTP Basic: Access denied<br>fatal: Authentication failed for &#039;http://git.midea.com/paas/customize-xxl-job.git/&#039;<br></div>

接下来,我们将用Script Console 去实现以上两种思路。提前说明两点:

  • 本次实验环境:Jenkins version 2.89.3
  • 实验脚本不建议直接在生产环境上使用

Groovy Script Console 介绍

Jenkins 提供了一个 Groovy 脚本控制台,允许用户在 Jenkins 主运行时或代理上的运行时中运行任意的 Groovy 脚本。它提供了可以做很多事情的能力:

  • 创建子进程,并在 Jenkins masteragents上执行任意命令;
  • 它甚至可以读取 Jenkins master上拥有访问权限的文件(比如 /etc/passwd);
  • 甚至可以解密Jenkins配置凭据
  • 普通用户若拥有使用Groovy Script Console 权限等同于拥有管理员权限;
  • Groovy Script Console之所以如此强大,是因为它最初是为 Jenkins 开发人员设计的一个调试界面,但后来发展成为 Jenkins Admin 用来配置 Jenkins调试 Jenkins runtime问题的一个界面;
  • 由于Groovy Script Console提供了强大的功能,Jenkins 及其Agents不应该以 root 用户身份在 Linux 上运行,在任何操作系统上也不应该以 root 用户身份运行;
  • 确保您的 Jenkins instance的安全;

在Agents上运行Groovy Script

可以在节点管理侧边栏菜单,点击脚本控制台即可。
2020-04-10 at 1.14 A
除此之外,也可以在MasterGroovy Script Console运行脚本执行到Agents

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hudson.util.RemotingDiagnostics
import jenkins.model.Jenkins

String agent_name = 'your agent name'
//groovy script you want executed on an agent
groovy_script = '''
println System.getenv("PATH")
println "uname -a".execute().text
'''.trim()

String result
Jenkins.instance.slaves.find { agent ->
agent.name == agent_name
}.with { agent ->
result = RemotingDiagnostics.executeGroovy(groovy_script, agent.channel)
}
println result

支持远程访问

通过Bash提交Groovy文件

1
2
curl --user 'username:api-token' --data-urlencode \
"script=$(< ./somescript.groovy)" https://jenkins/scriptText

示例案例

以下仓库中有许多可以作为参考的Groovy Script,基本上涉及大部分用户需要的场景:

举个栗子:一行代码,禁用所有Jobs

1
Jenkins.instance.getAllItems(hudson.model.AbstractProject.class).each {i -> i.setDisabled(true); i.save() }

参照Jenkins 2.89 接口文档,这行代码做的事情是获取所有的项目,一一设置为Disabled保存。

遍历所有上一次运行失败的项目,且日志中包含Access denied关键字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jobs = Jenkins.instance.getAllItems()
jobs.each { job ->
if (job instanceof com.cloudbees.hudson.plugins.folder.AbstractFolder) { return }
buildNums = job.getBuilds().size()
if(buildNums>0){
lastBuild = job.getLastBuild()
if(lastBuild && lastBuild.result == Result.FAILURE){
isAccessDenied = lastBuild.getLog().contains('Access denied')
if(isAccessDenied){
println 'JOB: ' + job.fullName + ' TimestampString: ' + lastBuild.getTimestampString2()
}
}
}
}

  • 遍历所有的Jobs,这里忽略了folder
  • 统计每个Job的运行次数
  • 对于运行过的Job获取最后一次Build,并判断对应的log中是否存在Access denied关键字
  • 打印符合条件的JOB信息

遍历所有项目,模拟调用check url请求,判断当前项目是否授信失效

对于这个思路,要解决的问题是如何模拟调用check url请求,我一开始的思路是,获取到每个任务对应的仓库信息和授信信息,从授信信息中获取明文信息后,然后直接使用HTTP请求gitlab判断,太麻烦了,还不如直接请求Jenkinscheck url请求。仔细观察请求URL``http://{host}:8081/job/test-fail-job-cased-by-credential-failed/descriptorByName/hudson.plugins.git.UserRemoteConfig/checkUrl猜测,Jenkins应该也是直接调用的git插件的checkUrl方法,于是找到git插件源码的UserRemoteConfig类,果然找到对应的doCheckUrl方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RequirePOST
public FormValidation doCheckUrl(@AncestorInPath Item item,
@QueryParameter String credentialsId,
@QueryParameter String value) throws IOException, InterruptedException {

// Normally this permission is hidden and implied by Item.CONFIGURE, so from a view-only form you will not be able to use this check.
// (TODO under certain circumstances being granted only USE_OWN might suffice, though this presumes a fix of JENKINS-31870.)
if (item == null && !Jenkins.get().hasPermission(Jenkins.ADMINISTER) ||
item != null && !item.hasPermission(CredentialsProvider.USE_ITEM)) {
return FormValidation.ok();
}

String url = Util.fixEmptyAndTrim(value);
if (url == null)
return FormValidation.error(Messages.UserRemoteConfig_CheckUrl_UrlIsNull());

if (url.indexOf('$') >= 0)
// set by variable, can't validate
return FormValidation.ok();

// get git executable on master
EnvVars environment;
Jenkins jenkins = Jenkins.get();
if (item instanceof Job) {
environment = ((Job) item).getEnvironment(jenkins, TaskListener.NULL);
} else {
Computer computer = jenkins.toComputer();
environment = computer == null ? new EnvVars() : computer.buildEnvironment(TaskListener.NULL);
}

GitClient git = Git.with(TaskListener.NULL, environment)
.using(GitTool.getDefaultInstallation().getGitExe())
.getClient();
StandardCredentials credential = lookupCredentials(item, credentialsId, url);
git.addDefaultCredentials(credential);

// Should not track credentials use in any checkURL method, rather should track
// credentials use at the point where the credential is used to perform an
// action (like poll the repository, clone the repository, publish a change
// to the repository).

// attempt to connect the provided URL
try {
git.getHeadRev(url, "HEAD");
} catch (GitException e) {
return FormValidation.error(Messages.UserRemoteConfig_FailedToConnect(e.getMessage()));
}

return FormValidation.ok();
}

所以只要调用UserRemoteConfig.DescriptorImpl内部类的doCheckUrl方法即可,最后实现的脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import hudson.plugins.git.*
UserRemoteConfig.DescriptorImpl descriptor = Jenkins.instance.getDescriptorByType(hudson.plugins.git.UserRemoteConfig.DescriptorImpl.class)

Jenkins.instance.getAllItems().each{job ->
if (job instanceof com.cloudbees.hudson.plugins.folder.AbstractFolder) { return }
if (job instanceof hudson.model.ExternalJob) { return }
def scmlist=[]
if(job instanceof org.jenkinsci.plugins.workflow.job.WorkflowJob){
scms = job.getSCMs()
scmlist.addAll(scms)
}else {
scmlist.add(job.scm)
}
if(scmlist){
def errorInfo=''
scmlist.each{scm ->
if(scm instanceof GitSCM){
scm.userRemoteConfigs.each{ urc ->
//println urc.name +' '+ urc.url +' '+ urc.credentialsId
formValidation = descriptor.doCheckUrl(job,urc.credentialsId,urc.url)
//println 'ERROR'==formValidation.kind.name()?formValidation:'ok'
if('ERROR'==formValidation.kind.name()){
errorInfo += "url: $urc.url, reason: $formValidation"
//println job.getAbsoluteUrl() + 'check git url failed,reason : '+formValidation
}
}
}
}
if(errorInfo?.trim()){
println job.getAbsoluteUrl() + 'check git url failed,error info : '+errorInfo
}
}

}
  • 需要特别import插件的包hudson.plugins.git.*
  • 通过Jenkins.instance.getDescriptorByType()获取UserRemoteConfig.DescriptorImpl的实例
  • 遍历所有项目,每个项目获取对应的SCM信息,其中包含了credentialsIdurl信息
  • 调用descriptor.doCheckUrl(job,urc.credentialsId,urc.url)方法验证授信是否失效
  • 打印所有失效的项目信息

彩蛋:解密Jenkins授信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import com.cloudbees.plugins.credentials.Credentials

Set<Credentials> allCredentials = new HashSet<Credentials>();

def creds = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(
com.cloudbees.plugins.credentials.common.StandardUsernameCredentials.class
);

allCredentials.addAll(creds)

Jenkins.instance.getAllItems(com.cloudbees.hudson.plugins.folder.Folder.class).each{ f ->
creds = com.cloudbees.plugins.credentials.CredentialsProvider.lookupCredentials(
com.cloudbees.plugins.credentials.common.StandardUsernameCredentials.class, f)
allCredentials.addAll(creds)

}

for (c in allCredentials) {
println( ( c.properties.privateKeySource ? "ID: " + c.id + ", UserName: " + c.username + ", Private Key: " + c.getPrivateKey() : ""))
println( ( c.properties.password ? "ID: " + c.id + ", UserName: " + c.username + ", Password: " + c.password : ""))
}

总结

感想如下:

  • 脚本调试成本很高:将逻辑拆分为多个步骤,按照步骤一步一步完善代码,上一步跑通后再实现下一步;
  • 对照Jenkins文档:建议参照对应版本JenkinsJavadoc,减少出错;
  • 经常出现“无该方法签名”的错误:通常是脚本中的字段或方法在当前版本的Jenkins中不存在,请检查Javadoc的版本和Jenkins版本是否一致;
  • 观察Jenkins是如何实现的:别着急实现,可以看看Jenkins自己是如何实现的,不然可能会走弯路;
  • 之前实现过一个功能,实时获取Jenkins正在执行和正在队列中的Job信息,当时还不知道有Script Console这种东西,实现方案是调用JenkinsHTTP API,获取XML的内容,再用特定的解析语法获取到Job信息。能获取到的Job字段内容很少,且还要处理XML解析逻辑。现在看来使用Script Console实现会更加简单快捷。

引用