Jenkins插件开发
通常我们基于Maven来开发Jenkins插件。
修改Maven配置文件,添加:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<profile> <id>jenkins</id> <repositories> <repository> <id>repo.jenkins-ci.org</id> <url>http://repo.jenkins-ci.org/public/</url> </repository> </repositories> <pluginRepositories> <pluginRepository> <id>repo.jenkins-ci.org</id> <url>http://repo.jenkins-ci.org/public/</url> </pluginRepository> </pluginRepositories> </profile> |
插件项目的父POM通常设置为:
1 2 3 4 5 6 |
<parent> <groupId>org.jenkins-ci.plugins</groupId> <artifactId>plugin</artifactId> <version>3.43</version> <relativePath /> </parent> |
此POM为构件插件提供了合理的缺省配置, 修改属性以定制配置项:
1 2 3 4 |
<properties> <jenkins.version>2.60.1</jenkins.version> <java.level>8</java.level> </properties> |
这是一个Maven插件,用于构建Jenkins插件。
此目标用于创建一个新的插件项目骨架。目前已经废弃,应当考虑基于原型生成插件项目骨架
目标完整名称:org.jenkins-ci.tools:maven-hpi-plugin:3.7:hpi。构建Jenkins插件的WAR包。
此目标的必需参数:
参数 | 说明 |
minimumJavaVersion |
运行此插件需要的最低Java版本 |
此目标主要的可选参数:
参数 | 说明 |
compatibleSinceVersion | 当前插件版本,在配置上兼容的最低插件版本号 |
maskClasses |
空白符分隔的Java包前缀,插件不希望使用Jenkins Core提供的这些类 每个包前缀应当以 . 结尾 |
globalMaskClasses |
类似于maskClasses,但是作用于Jenkins Core和所有插件的边界 主要供哪些提供JavaEE API的插件使用,例如提供JPA API的database插件。其它依赖于database插件的插件,亦可通过container类加载器看到JPA API |
hpiName | 生成的HPI的名称 |
pluginFirstClassLoader | 设置为true,改变类加载器优先级,让插件自带的类优先于插件依赖的其它插件的类加载 |
类似hpi:hpi,但是打包为Debug布局。
目标完整名称:org.jenkins-ci.tools:maven-hpi-plugin:3.7:run。使用当前插件来运行Jenkins。
此目标的必需参数:
参数 | 说明 |
minimumJavaVersion |
运行此插件需要的最低Java版本 |
此目标主要的可选参数:
参数 | 说明 |
consoleForceReload | 如果设置为true,在输入控制台上输入新行,则Web上下文自动重启 |
defaultPort | 默认HTTP端口 |
hudsonHome jenkinsHome |
$JENKINS_HOME的位置,启动的Jenkins服务器以此目录为工作空间 |
jenkinsCoreId | Jenkins Core 包的groupId:artifactId |
reload | 可选值automatic | manual,设置为automatic则文件改变后自动reload,否则输入控制台上输入新行后reload |
maskClasses | 参考HPI |
pluginFirstClassLoader | 参考HPI |
可以通过mvnDebug进行Jenkins插件的单步跟踪。
1 2 3 4 5 |
mvnDebug hpi:run # 或者 export MAVEN_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,address=8000,suspend=n" mvn hpi:run |
使用系统属性来调整Jenkins的HTTP端口,以及上下文路径:
1 |
mvn hpi:run -Djetty.port=8090 -Dhpi.prefix=/jenkins |
现在可以通过Gradle JPI plugin,基于Gradle开发Jenkins插件。
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
plugins { id 'org.jenkins-ci.jpi' version '0.33.0' } group = 'org.jenkins-ci.plugins' version = '1.2.0-SNAPSHOT' description = 'A description of your plugin' jenkinsPlugin { // 依赖的Jenkins核心版本 coreVersion = '1.420' // 插件的标识符,默认为项目名去掉-plugin后缀 shortName = 'hello-world' // 人类可读的名称 displayName = 'Hello World plugin built with Gradle' // 在Jenkins Wiki上的插件页面 url = 'http://wiki.jenkins-ci.org/display/JENKINS/SomePluginPage' // 可选的插件源码库地址 gitHubUrl = 'https://github.com/jenkinsci/some-plugin' // 是否让插件Classloader优先于核心Classloader加载类 pluginFirstClassLoader = true // 不希望看到的核心的类前缀 maskClasses = 'groovy.grape org.apache.commons.codec' // 当前版本的配置和什么版本之后的兼容 compatibleSinceVersion = '1.1.0' // 开发服务器的工作目录 workDir = file('/tmp/jenkins') // 部署此插件到什么地方 repoUrl = 'https://repo.jenkins-ci.org/releases' // 部署此插件的快照版本到什么地方调试插件 snapshotRepoUrl = 'https://repo.jenkins-ci.org/snapshots' // 是否禁用测试的依赖注入,用于检查Jelly语法等 disabledTestInjection = false // 本地化任务输出目录 localizerOutputDir = "${project.buildDir}/generated-src/localizer" // 是否配置中心仓库、本地仓库、Jenkins中心仓库 configureRepositories = false // 是否配置部署仓库 configurePublishing = false // 插件扩展名,hpi或者jpi fileExtension = 'hpi' // 开发者列表 developers { developer { id 'abayer' name 'Andrew Bayer' email 'andrew.bayer@gmail.com' } } // License信息 licenses { license { name 'Apache License, Version 2.0' url 'https://www.apache.org/licenses/LICENSE-2.0.txt' distribution 'repo' comments 'A business-friendly OSS license' } } } dependencies { // 依赖其它插件,目标插件的类在编译期可见 jenkinsPlugins 'org.jenkinsci.plugins:git:1.1.15' // 可选依赖 optionalJenkinsPlugins 'org.jenkins-ci.plugins:ant:1.2' // 测试依赖 jenkinsTest 'org.jenkins-ci.main:maven-plugin:1.480' // 为server任务安装额外的插件 jenkinsServer 'org.jenkins-ci.plugins:ant:1.2' } |
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 |
plugins { id 'groovy' id "org.jenkins-ci.jpi" version "0.33.0" } sourceCompatibility = 1.8 group 'io.jenkins.plugins' version '1.0-SNAPSHOT' description = "This plugin provides pipeline steps for ALCM." repositories { maven { url 'http://maven.aliyun.com/nexus/content/groups/public/' } maven { url 'https://repo.jenkins-ci.org/public/' } mavenLocal() } jenkinsPlugin { coreVersion = "2.164.1" displayName = "PA ALCM Steps" url = "http://wiki.jenkins-ci.org/display/JENKINS/ALCMSteps" shortName = "alcm-steps" workDir = file('/tmp/jenkins') fileExtension = 'jpi' pluginFirstClassLoader = true } dependencies { compile 'org.codehaus.groovy:groovy-all:2.3.11' compile 'cc.gmem.yun.alcm:client:1.0-SNAPSHOT' compile 'org.jenkins-ci.plugins:structs:1.19' testCompile group: 'junit', name: 'junit', version: '4.12' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-step-api:2.19' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-cps:2.39' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-job:2.11.2' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-basic-steps:2.6' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-durable-task-step:2.13' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-api:2.30' jenkinsTest 'org.jenkins-ci.plugins.workflow:workflow-support:3.3' jenkinsTest 'org.jenkins-ci.plugins:script-security:1.39' jenkinsTest 'org.jenkins-ci.plugins:scm-api:2.2.6' } |
任务 | 说明 |
gradle jpi | 构建出插件 |
gradle publishToMavenLocal | 构建并安装到本地Maven仓库 |
gradle publish | 部署到Jenkins的Maven仓库,以便在Update Center可见 |
gradle server | 启动本地Jenkins实例便于调试: -Djenkins.httpPort=8082 |
1 2 3 4 5 6 7 8 |
# 调试 ./gradlew server -Dorg.gradle.debug=true # 定制端口 /gradlew server -Dorg.gradle.jvmargs=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 # 禁用Jelly缓存 ./gradlew -Dstapler.jelly.noCache=false server |
Jenkins提供了一系列插件的原型,我们可以根据基于这些原型生成自己的插件的骨架代码:
1 2 3 4 5 6 7 8 |
mvn -U -P jenkins archetype:generate -Dfilter="io.jenkins.archetypes:" # 非交互式 mvn archetype:generate -P jenkins -B -DarchetypeGroupId=io.jenkins.archetypes \ -DarchetypeArtifactId=hello-world-plugin \ -DarchetypeVersion=1.5 \ -DgroupId=cc.gmem.yun.alcm -DartifactId=alcm-steps -Dversion=1.0-SNAPSHOT \ -Dpackage=cc.gmem.yun.alcm.pipeline.jenkins.steps |
从列表中选择一个合适的原型,然后根据提示填写各项参数即可。
原型 | 说明 |
io.jenkins.archetypes:empty-plugin | 仅仅包含POM和空白的源码树 |
io.jenkins.archetypes:global-configuration-plugin | 全局配置插件的示例 |
io.jenkins.archetypes:hello-world-plugin | 包含POM和一个示例的Build Step |
本章后续内容均针对基于hello-world-plugin生成的代码骨架。
从原型生成的插件目录结构如下:
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 |
├── alcm-steps.iml ├── pom.xml ├── src │ ├── main │ │ ├── java # 插件源代码 │ │ │ └── cc │ │ │ └── gmem │ │ │ └── yun │ │ │ └── alcm │ │ │ └── pipeline │ │ │ └── jenkins │ │ │ └── steps │ │ │ └── HelloWorldBuilder.java # 插件类 │ │ ├── resources # Jelly/Groovy视图文件 │ │ │ ├── index.jelly # 插件说明信息 │ │ │ ├── cc │ │ │ │ └── gmem │ │ │ │ └── yun │ │ │ │ └── alcm │ │ │ │ └── pipeline │ │ │ │ └── jenkins │ │ │ │ └── steps │ │ │ │ ├── HelloWorldBuilder │ │ │ │ │ ├── config.jelly # 插件配置页面模板 │ │ │ │ │ ├── config.properties # 模板变量值 │ │ │ │ │ ├── config_zh_CN.properties # 模板变量值(国际化) │ │ │ │ │ ├── help-name.html # 字段name的帮助信息 │ │ │ │ │ ├── help-name_zh_CN.html │ │ │ │ │ ├── help-useFrench.html │ │ │ │ │ └── help-useFrench_zh_CN.html │ │ └── webapp # 插件的静态资源 |
该插件会为Jenkins Job提供一个Step,此Step的配置界面如下:
Name字段的名称为name,点击后面的问号,显示的内容是help-name.html中的。
此类是插件的实现。它继承Builder,实现了SimpleBuildStep。
SimpleBuildStep是诸如Builder、Publisher之类的Step,可以在构建过程的任何时机调用多次。这些Step应当遵循:
- 不去实现BuildStep.prebuild方法,因为此方法假设了一种特定的执行顺序
- 不去实现BuildStep.getProjectActions方法,因为如果此Step不是项目的静态配置的一部分,则它可能永不会被调用
- 实现BuildStep.getRequiredMonitorService,且返回BuildStepMonitor.NONE,因为只对仅调用一次的Step有意义
- 不去实现DependencyDeclarer
- 不假设Executor.currentExecutor为非空,不使用Computer.currentComputer
从抽象类Builder继承的方法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public abstract class Builder extends BuildStepCompatibilityLayer implements Describable<Builder>, ExtensionPoint { public boolean prebuild(Build build, BuildListener listener) { return true; } // 由于Builder通常不依赖于它前面的步骤的结果,因此默认返回NONE public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; } public Descriptor<Builder> getDescriptor() { return Jenkins.getInstance().getDescriptorOrDie(getClass()); } public static DescriptorExtensionList<Builder,Descriptor<Builder>> all() { return Jenkins.getInstance().<Builder,Descriptor<Builder>>getDescriptorList(Builder.class); } } |
HelloWorldBuilder是插件的实现,实现插件不需要继承plugin类(这种方式已经废弃),推荐的方式是实现扩展点,并通过@hudson.Extension注解来注册(以便Jenkins自动发现)。
关于这种扩展的方式,需要注意:
- 一般来说,你的插件应该继承已有的扩展点(实现ExtensionPoint的类),并在静态内部类中继承对应的描述符类,这些描述符类是hudson.model.Descriptor的子类
- 注解@Extension必须放在上述内部类之上,这样Jenkins才能发现扩展
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
package cc.gmem.yun.alcm.pipeline.jenkins.steps; import hudson.Launcher; import hudson.Extension; import hudson.FilePath; import hudson.util.FormValidation; import hudson.model.AbstractProject; import hudson.model.Run; import hudson.model.TaskListener; import hudson.tasks.Builder; import hudson.tasks.BuildStepDescriptor; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import javax.servlet.ServletException; import java.io.IOException; import jenkins.tasks.SimpleBuildStep; import org.jenkinsci.Symbol; import org.kohsuke.stapler.DataBoundSetter; public class HelloWorldBuilder extends Builder implements SimpleBuildStep { private final String name; private boolean useFrench; // 绑定对象时使用此构造器 @DataBoundConstructor public HelloWorldBuilder(String name) { this.name = name; } public String getName() { return name; } public boolean isUseFrench() { return useFrench; } @DataBoundSetter public void setUseFrench(boolean useFrench) { this.useFrench = useFrench; } /** * SimpleBuildStep的核心方法,执行此Step * @param run 此Step所属的Build * @param workspace 此Build的工作区,用于文件系统操作 * @param launcher 用于启动进程 * @param listener 用于发送输出 * @throws InterruptedException 如果此Step被中断 * @throws IOException 出现其它错误,更“礼貌”的错误是AbortException */ public void perform(Run<?, ?> run, FilePath workspace, Launcher launcher, TaskListener listener) throws InterruptedException, IOException { if (useFrench) { listener.getLogger().println("Bonjour, " + name + "!"); } else { listener.getLogger().println("Hello, " + name + "!"); } } // 此注解为Jenkins扩展定义唯一的标识符,驼峰式大小写,尽量简短。在流水线脚本中引用此Step时即使用此标识符 // 标识符不需要全局唯一,只需要在一个扩展点内是唯一的即可 @Symbol("greet") // 标记字段、方法或类,以便Huson能自动发现定位到ExtensionPoint的实现 @Extension // Build / Publisher的描述符 // Descriptor是可配置实例(Describable)的元数据,也作为Describable的工厂 public static final class DescriptorImpl extends BuildStepDescriptor<Builder> { // 校验Project配置表单中的字段 // 下面的方法检查name字段,参数value注入name的值,可以用@QueryParameter注入其它参数 // 如果要校验useFrench字段,则需要实现doCheckUsdeFrench方法 public FormValidation doCheckName(@QueryParameter String value, @QueryParameter boolean useFrench) throws IOException, ServletException { if (value.length() == 0) // 引用资源束 return FormValidation.error(Messages.HelloWorldBuilder_DescriptorImpl_errors_missingName()); if (value.length() < 4) return FormValidation.warning(Messages.HelloWorldBuilder_DescriptorImpl_warnings_tooShort()); if (!useFrench && value.matches(".*[éáàç].*")) { return FormValidation.warning(Messages.HelloWorldBuilder_DescriptorImpl_warnings_reallyFrench()); } return FormValidation.ok(); } // 判断当前Step是否能用于目标类型的项目 @Override public boolean isApplicable(Class<? extends AbstractProject> aClass) { return true; } // 此Step的显示名称 @Override public String getDisplayName() { return Messages.HelloWorldBuilder_DescriptorImpl_DisplayName(); } } } |
此文件是Project配置页面的模板:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<?jelly escape-by-default='true'?> <j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:t="/lib/hudson" xmlns:f="/lib/form"> <!-- 文本表单字段,${%引用资源束中的条目 --> <f:entry title="${%Name}" field="name"> <f:textbox /> </f:entry> <f:advanced> <f:entry title="${%French}" field="useFrench" description="${%FrenchDescr}"> <f:checkbox /> </f:entry> </f:advanced> </j:jelly> |
模板使用的资源束为config.properties。
这个类包含了单元测试:
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
package cc.gmem.yun.alcm.pipeline.jenkins.steps; import hudson.model.FreeStyleBuild; import hudson.model.FreeStyleProject; import hudson.model.Label; import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; public class HelloWorldBuilderTest { // 标记此字段为TestRule // JUnit的TestRule决定一个或一组测试方法如何运行、如何报告 @Rule public JenkinsRule jenkins = new JenkinsRule(); final String name = "Bobby"; @Test public void testConfigRoundtrip() throws Exception { // 启动一个临时Jenkins服务器,并创建自由项目 FreeStyleProject project = jenkins.createFreeStyleProject(); // 添加一个构建步骤 project.getBuildersList().add(new HelloWorldBuilder(name)); // 加载配置页面,不经修改的提交 project = jenkins.configRoundtrip(project); // 判断相等 jenkins.assertEqualDataBoundBeans(new HelloWorldBuilder(name), project.getBuildersList().get(0)); } @Test public void testConfigRoundtripFrench() throws Exception { FreeStyleProject project = jenkins.createFreeStyleProject(); HelloWorldBuilder builder = new HelloWorldBuilder(name); builder.setUseFrench(true); project.getBuildersList().add(builder); project = jenkins.configRoundtrip(project); HelloWorldBuilder lhs = new HelloWorldBuilder(name); lhs.setUseFrench(true); jenkins.assertEqualDataBoundBeans(lhs, project.getBuildersList().get(0)); } @Test public void testBuild() throws Exception { FreeStyleProject project = jenkins.createFreeStyleProject(); HelloWorldBuilder builder = new HelloWorldBuilder(name); project.getBuildersList().add(builder); // 执行构建,并断言构建成功 FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); // 断言本次构建的控制台输出包括指定字样 jenkins.assertLogContains("Hello, " + name, build); } @Test public void testBuildFrench() throws Exception { FreeStyleProject project = jenkins.createFreeStyleProject(); HelloWorldBuilder builder = new HelloWorldBuilder(name); builder.setUseFrench(true); project.getBuildersList().add(builder); FreeStyleBuild build = jenkins.buildAndAssertSuccess(project); jenkins.assertLogContains("Bonjour, " + name, build); } // 在流水线中使用此Step @Test public void testScriptedPipeline() throws Exception { String agentLabel = "my-agent"; // 在本机创建一个新的Slave(Agent),等待其上线 jenkins.createOnlineSlave(Label.get(agentLabel)); // 定义一个流水线任务 WorkflowJob job = jenkins.createProject(WorkflowJob.class, "test-scripted-pipeline"); // 流水线定义,注意通过@Symbol指定的标识符来引用本插件定义的Step String pipelineScript = "node {\n" + " greet '" + name + "'\n" + "}"; // CPS,即continuation passing style,是运行Groovy脚本的一种方式 // 允许应用程序被随时暂停、重启继续 job.setDefinition(new CpsFlowDefinition(pipelineScript, true)); WorkflowRun completedBuild = jenkins.assertBuildStatusSuccess(job.scheduleBuild2(0)); String expectedString = "Hello, " + name + "!"; jenkins.assertLogContains(expectedString, completedBuild); } } |
Jenkins插件的依赖注入,可以基于Google Guice(一个轻量级的IoC框架)实现。下面是一个例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 需要被注入的接口及其实现 public interace MySvc { public void myServiceMethod() } public class MySvcImpl implements MySvc { public void myServiceMethod() { System.out.println("It works!"); } } // IoC配置类 public class MyGuiceModule extends com.google.inject.AbstractModule { @Override public void configure() { bind(MySvc.class).to(MySvcImpl.class).in(com.google.inject.Singleton.class); } } |
下面的插件类会被注入MySvc:
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 |
import com.google.inject.Guice; import com.google.inject.Inject; public class MyPublisher extends Publisher { private transient MySvc mySvc; private String someJobConfig @DataBoundConstructor public MyPublisher(String someJobConfig) { this.someJobConfig = someJobConfig; } @Inject public void setMySvc(MySvc mySvc) { this.mySvc = mySvc; } @Override public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) { if (mySvc == null) { // 进行注入 Guice.createInjector(new MyModule()).injectMembers(this); } mySvc.myServiceMethod(); } } |
每当构建时, perform(Build, Launcher, BuildListener)方法会被调用,你可以利用Launcher来执行任意命令:
1 2 3 4 5 6 7 8 9 |
launcher.launch(cmd, env, out, workDir) // 启动进程 launcher.launch("dir", new String[0], listener.getLogger(), build.getProject().getWorkspace()); // 获取返回值 Proc proc = launcher.launch("dir", build.getEnvVars(), listener.getLogger(), build.getProject().getWorkspace()); int exitCode = proc.join(); |
Jenkins使用一种分布式机制来运行任务 —— 运行在Master节点上的线程,能够发送闭包到远程机器上,并且在闭包执行完毕后,取回结果。
支持分布式执行的闭包示例如下:
1 2 3 4 5 6 |
private static class GetSystemProperties extends MasterToSlaveCallable<Properties,RuntimeException> { public Properties call() { return System.getProperties(); } private static final long serialVersionUID = 1L; } |
关键之处在于需要实现hudson.remoting.Callable,此接口声明了返回值、异常类型。
下面的调用将闭包发送给Slave执行:
1 |
Properties systemProperties = channel.call(new GetSystemProperties()); |
Jenkins提供了一些关键的抽象,将其分布式执行的特性隐藏起来。
抽象 | 说明 |
hudson.FilePath | 用于代替java.io.File,可以指向位于Master或任何Slave上的目录或文件 |
hudson.Launcher | 类似于 java.lang.ProcessBuilder,但是可以在远程JVM上启动进程 |
你需要把图片放在src/main/webapp下。在jelly或者Java页面上获取图片的方法是:
1 2 3 4 |
public String getIconPath() { PluginWrapper wrapper = Hudson.getInstance().getPluginManager().getPlugin([YOUR-PLUGIN-MAIN-CLASS].class); return Hudson.getInstance().getRootUrl() + "plugin/"+ wrapper.getShortName()+"/"; } |
要在jelly页面添加空格,只需要: <st:nbsp/>
本章分析一些真实的Jenkins插件的代码。
Publisher可以在构建完成后,触发任意的动作。Recorder可以为每次构建记录一些统计信息。
Discard Old Build plugin是这种扩展的例子,用于在构建完毕后,检查并删除过期的构建历史。本节分析其代码:
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 |
package org.jenkinsci.plugins.discardbuild; import hudson.Extension; import hudson.Launcher; import hudson.model.*; import hudson.tasks.BuildStepDescriptor; import hudson.tasks.BuildStepMonitor; import hudson.tasks.Publisher; import hudson.tasks.Recorder; import hudson.util.RunList; import org.kohsuke.stapler.DataBoundConstructor; import java.io.BufferedReader; import java.io.File; import java.io.FileReader; import java.io.IOException; import java.util.ArrayList; import java.util.Calendar; import java.util.HashSet; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; public class DiscardBuildPublisher extends Recorder { // 插件参数 /** * If not -1, history is only kept up to this days. */ private final int daysToKeep; // 从表单绑定插件参数 @DataBoundConstructor public DiscardBuildPublisher( String daysToKeep) { this.daysToKeep = parse(daysToKeep); } private void deleteOldBuildsByDays(AbstractBuild<?, ?> build, BuildListener listener, int daysToKeep) { ArrayList<Run<?, ?>> list = updateBuildsList(build, listener); try { Calendar cal = getCurrentCalendar(); cal.add(Calendar.DAY_OF_YEAR, -daysToKeep); for (Run<?, ?> r : list) { if (r.getTimestamp().before(cal)) { discardBuild(r, "it is older than daysToKeep", listener); //$NON-NLS-1$ } } } catch (IOException e) { e.printStackTrace(listener.error("")); //$NON-NLS-1$ } } private ArrayList<Run<?, ?>> updateBuildsList(AbstractBuild<?, ?> build, BuildListener listener) { RunList<Run<?, ?>> builds = new RunList<Run<?, ?>>(); ArrayList<Run<?, ?>> list = new ArrayList<Run<?, ?>>(); // 获取本次Build对应的Job Job<?, ?> job = (Job<?, ?>) build.getParent(); // 获取Job的所有构建 builds = (RunList<Run<?, ?>>) job.getBuilds(); list = discardLastBuilds(build, listener, builds); return list; } @Override public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) { listener.getLogger().println("Discard old builds..."); //$NON-NLS-1$ deleteOldBuildsByDays(build, listener, daysToKeep); return true; } private void discardBuild(Run<?, ?> history, String reason, BuildListener listener) throws IOException { listener.getLogger().printf("#%d is removed because %s\n", history.getNumber(), reason); //$NON-NLS-1$ history.delete(); } @Override public DescriptorImpl getDescriptor() { return (DescriptorImpl) super.getDescriptor(); } // 描述符 @Extension public static final class DescriptorImpl extends BuildStepDescriptor<Publisher> { @SuppressWarnings("rawtypes") public boolean isApplicable(Class<? extends AbstractProject> aClass) { return true; } /** * This human readable name is used in the configuration screen. */ public String getDisplayName() { return Messages.DiscardHistoryBuilder_description(); } } public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; } } |
这个扩展点用于触发某个Job的构建。
Files Found Trigger是这种扩展的例子,能够轮询一个或多个目录,当发现特定的文件后,自动触发构建。本节分析其代码。
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 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 |
package hudson.plugins.filesfoundtrigger; import static hudson.Util.fixNull; import java.io.IOException; import java.text.MessageFormat; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicLong; import java.util.logging.Level; import java.util.logging.Logger; import org.apache.commons.lang.builder.ToStringBuilder; import org.apache.commons.lang.builder.ToStringStyle; import org.kohsuke.stapler.DataBoundConstructor; import com.google.common.collect.ImmutableList; import com.thoughtworks.xstream.converters.Converter; import com.thoughtworks.xstream.converters.reflection.PureJavaReflectionProvider; import com.thoughtworks.xstream.mapper.Mapper; import antlr.ANTLRException; import hudson.Extension; import hudson.model.BuildableItem; import hudson.model.Item; import hudson.triggers.Trigger; import hudson.triggers.TriggerDescriptor; import hudson.util.RobustReflectionConverter; public final class FilesFoundTrigger extends Trigger<BuildableItem> { private static final Logger LOGGER = Logger.getLogger(FilesFoundTrigger.class.getName()); private static final AtomicLong logCounter = new AtomicLong(); // 从什么Slave节点上检查文件是否存在,如果为空则检查master节点 private final String node; // 检查的目录 private final String directory; // 期望存在的文件的pattern private final String files; // 忽略的文件的pattern private final String ignoredFiles; // 仅仅当发现文件数量大于此数字时才触发 private final String triggerNumber; // 额外的文件pattern private final ArrayList<FilesFoundTriggerConfig> additionalConfigs; /** * 构造函数 * * @param spec * 轮询文件系统的Cron表达式 * @param configs * 文件模式列表 * @throws ANTLRException * 无法解析Cron表达式抛出此异常 */ @DataBoundConstructor public FilesFoundTrigger(String spec, List<FilesFoundTriggerConfig> configs) throws ANTLRException { // Trigger天生基于Cron进行检查 super(spec); ArrayList<FilesFoundTriggerConfig> configsCopy = new ArrayList<FilesFoundTriggerConfig>(fixNull(configs)); FilesFoundTriggerConfig firstConfig; if (configsCopy.isEmpty()) { firstConfig = new FilesFoundTriggerConfig(null, "", "", "", "1"); } else { firstConfig = configsCopy.remove(0); } this.node = firstConfig.getNode(); this.directory = firstConfig.getDirectory(); this.files = firstConfig.getFiles(); this.ignoredFiles = firstConfig.getIgnoredFiles(); this.triggerNumber = firstConfig.getTriggerNumber(); if (configsCopy.isEmpty()) { configsCopy = null; } this.additionalConfigs = configsCopy; } public List<FilesFoundTriggerConfig> getConfigs() { ImmutableList.Builder<FilesFoundTriggerConfig> builder = ImmutableList.builder(); builder.add(new FilesFoundTriggerConfig(node, directory, files, ignoredFiles, triggerNumber)); if (additionalConfigs != null) { builder.addAll(additionalConfigs); } return builder.build(); } // Cron每次触发的逻辑 @Override public void run() { long counter = logCounter.incrementAndGet(); for (FilesFoundTriggerConfig config : getConfigs()) { FilesFoundTriggerConfig expandedConfig = config.expand(); try { FileSearch.Result result = FileSearch.perform(expandedConfig); int triggerNumber = Integer.parseInt(expandedConfig.getTriggerNumber()); boolean triggerBuild = result.files.size() >= triggerNumber; if (triggerBuild) { // 触发构建 job.scheduleBuild(0, new FilesFoundTriggerCause(expandedConfig)); return; } } catch (NumberFormatException e) { LOGGER.log(Level.FINE, "{0} - Result: Invalid trigger number (build not triggered)", counter); } catch (InterruptedException e) { LOGGER.log(Level.FINE, "{0} - Result: Thread interrupted (build not triggered)", counter); Thread.currentThread().interrupt(); } } } // 将当前插件注册为Trigger扩展 @Extension public static final class DescriptorImpl extends TriggerDescriptor { @Override public boolean isApplicable(Item item) { return item instanceof BuildableItem; } @Override public String getDisplayName() { return Messages.DisplayName(); } } } |
Jenkins提供一系列JUnit周边工具来简化单元测试。支持的特性包括:
- 启动内嵌的Servlet容器,允许通过浏览器访问Jenkins页面,进行测试
- HtmlUnit用于简化UI测试
- 为每个测试用例准备隔离的Jenkins实例
- 测试代码可以直接访问Jenkins对象模型,并可直接执行一些操作,而不需要通过浏览器
- 基于注解的、声明式的测试用例环境说明
下面的例子示例了如何基于JenkinsRule进行单元测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import org.jvnet.hudson.test.JenkinsRule; import org.apache.commons.io.FileUtils; import hudson.model.*; import hudson.tasks.Shell; import org.junit.Test; import org.junit.Rule; public class AppTest { @Rule public JenkinsRule j = new JenkinsRule(); @Test public void first() throws Exception { // 自由风格项目 FreeStyleProject project = j.createFreeStyleProject(); // 添加一个构建步骤 project.getBuildersList().add(new Shell("echo hello")); // 得到某个构建步骤 Shell shell = project.getBuildersList().get(Shell.class); // 调度构建并获得结果 FreeStyleBuild build = project.scheduleBuild2(0).get(); System.out.println(build.getDisplayName() + " completed"); // 读取日志文件内容 String s = FileUtils.readFileToString(build.getLogFile()); assertThat(s, contains("+ echo hello")); } } |
某些情况下,你希望在不依赖于完整Jenkins实例的前提下进行快速测试,这时可以使用仿冒:
1 2 |
AbstractBuild build = Mockito.mock(AbstractBuild.class); Mockito.when(build.getResult()).thenReturn(Result.FAILURE); |
用于测试Jenkins生成的HTML页面:
1 2 3 |
HtmlPage page = j.createWebClient().goTo("computer/test/"); HtmlElement navbar = page.getElementById("left-top-nav"); assertEquals(1,navbar.selectNodes(".//a").size()); |
1 2 3 |
HtmlPage configPage = j.createWebClient().goTo("configure"); HtmlForm form = configPage.getFormByName("config"); form.submit((HtmlButton)last(form.getHtmlElementsByTagName("button"))); |
1 2 3 4 5 6 |
public void setEnvironmentVariables() throws IOException { EnvironmentVariablesNodeProperty prop = new EnvironmentVariablesNodeProperty(); EnvVars envVars = prop.getEnvVars(); envVars.put("sampleEnvVarKey", "sampleEnvVarValue"); j.jenkins.getGlobalNodeProperties().add(prop); } |
下面的代码允许任何用户名登陆,只要输入的密码和用户名一致:
1 |
j.jenkins.setSecurityRealm(j.createDummySecurityRealm()); |
要实现一个一次性的Builder,可以选择继承TestBuilder:
1 2 3 4 5 6 7 8 9 10 |
FreeStyleProject project = j.createFreeStyleProject(); project.getBuildersList().add(new TestBuilder() { public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { // 向工作区中的文件中写入数据 build.getWorkspace().child("abc.txt").write("hello","UTF-8"); return true; } }); project.scheduleBuild2(0); |
Jenkins中构建时异步发生的,如果需要在测试代码中进行同步,可以使用OneShotEvent:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
final OneShotEvent buildStarted = new OneShotEvent(); FreeStyleProject project = j.createFreeStyleProject(); project.getBuildersList().add(new TestBuilder() { public boolean perform(AbstractBuild<?, ?> build, Launcher launcher, BuildListener listener) throws InterruptedException, IOException { // 构建真正开始后,发起信号 buildStarted.signal(); return true; } }); project.scheduleBuild2(0); // 阻塞直到信号到达 buildStarted.block(); |
如果希望使用其它Jenkins插件提供的类,只需要将目标插件声明为Maven依赖即可。很多插件专门提供基础功能,供其它插件依赖,例如Git Client Plugin。
应当尽量依赖插件(而不是自己引入jar依赖),因为hpi打包时,依赖的插件(以及这些插件的传递性依赖)会自动排除,可以减小压缩包的大小。
共享库没有ClassLoader隔离机制,Jenkins Core的同名类会优先使用,从而导致冲突。
解决办法是,将存在冲突的部分代码封装到插件中,然后共享库调用插件。
插件具有类隔离机制,通过适当的配置即可避免和Jenkins Core的类冲突。但是,在基于JenkinsRule进行测试时,这种类隔离机制不生效,需要修改JenkinsRule的源码。
Leave a Reply