From bf4db287fce998a9e809aa24c4c7957f3fe31207 Mon Sep 17 00:00:00 2001 From: Jens Thielscher Date: Fri, 12 Nov 2021 11:00:55 +0100 Subject: [PATCH] support dynamic templates for pipelines --- README.md | 60 +++++++++ pom.xml | 33 ++++- .../jenkinsci/plugins/nomad/NomadCloud.java | 32 +++-- .../nomad/pipeline/NomadPipelineStep.java | 100 ++++++++++++++ .../nomad/pipeline/NomadStepExecution.java | 88 ++++++++++++ .../plugins/nomad/NomadCloudTest.java | 40 +++++- .../nomad/pipeline/NomadPipelineStepTest.java | 126 ++++++++++++++++++ 7 files changed, 464 insertions(+), 15 deletions(-) create mode 100644 src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStep.java create mode 100644 src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadStepExecution.java create mode 100644 src/test/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStepTest.java diff --git a/README.md b/README.md index 5de7cef4..73464144 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,63 @@ Note that, in each case, the certificates: * Must be files reachable by the Jenkins controller. * Must be in the [PKCS12 format](https://en.wikipedia.org/wiki/PKCS_12). + +## Pipeline support +For pipelines, nodes can be started by using the label which is assigned to a predefined job template. +``` +node ('my-label') { +} +``` +Or the template can be provided directly (either in JSON or HCL). +``` +nomad (jobTemplate:''' +job "%WORKER_NAME%" { + region = "global" + type = "batch" + datacenters = ["dc1"] + group "jenkins-worker-taskgroup" { + count = 1 + restart { + attempts = 0 + mode = "fail" + } + task "jenkins-worker" { + driver = "docker" + config { + image = "jenkins/inbound-agent" + force_pull = true + } + env { + JENKINS_URL = "https://jenkins.service.consul" + JENKINS_AGENT_NAME = "%WORKER_NAME%" + JENKINS_SECRET = "%WORKER_SECRET%" + JENKINS_WEB_SOCKET = "true" + } + resources { + cpu = 500 + memory = 256 + } + } + } +} +''') { + node (JOB_LABEL) { + sh 'hostname' + } +} +``` +Note: The `JOB_LABEL` environment variable is defined by the `nomad` pipeline step with the corresponding label of the `dynamic template`. + +Via `readFile/readTrusted`, the `jobTemplate` can also be referenced from the file system or from the repository. +``` +nomad (jobTemplate: readTrusted('src/test/resources/nomad-templates/build.yaml')) { + node (JOB_LABEL) { + sh 'hostname' + } +} +``` + +Optional parameters: +* `cloud` - Name of the Nomad cloud configuration you want to use (Default: `null` which means the first Nomad cloud is selected automatically) +* `remoteFS` - Absolute path to the working directory of the remote agent (Default: `null` which means the environment variable `hudson.model.slave.workspaceDir` or `JENKINS_HOME` is used instead) +* `prefix` - Node name is prefixed with this value (Default: `jenkins-dt`) diff --git a/pom.xml b/pom.xml index 90119956..07eaacfb 100644 --- a/pom.xml +++ b/pom.xml @@ -77,6 +77,18 @@ + + + + io.jenkins.tools.bom + bom-2.289.x + 987.v4ade2e49fe70 + import + pom + + + + com.squareup.okhttp3 @@ -96,12 +108,14 @@ org.jenkins-ci.plugins credentials - 2.6.1 org.jenkins-ci.plugins plain-credentials - 1.7 + + + org.jenkins-ci.plugins.workflow + workflow-step-api @@ -131,5 +145,20 @@ + + org.jenkins-ci.plugins.workflow + workflow-job + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + test + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + test + diff --git a/src/main/java/org/jenkinsci/plugins/nomad/NomadCloud.java b/src/main/java/org/jenkinsci/plugins/nomad/NomadCloud.java index 4ebf565a..df1c50e7 100644 --- a/src/main/java/org/jenkinsci/plugins/nomad/NomadCloud.java +++ b/src/main/java/org/jenkinsci/plugins/nomad/NomadCloud.java @@ -20,6 +20,7 @@ import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.stream.Stream; import org.jenkinsci.plugins.nomad.Api.JobInfo; import org.jenkinsci.plugins.plaincredentials.StringCredentials; @@ -65,6 +66,7 @@ public class NomadCloud extends AbstractCloudImpl { // non persistent fields private transient NomadApi nomad; private transient int pending = 0; + private transient List dynamicTemplates; // legacy fields (we have to keep them for backward compatibility) private transient String jenkinsUrl; @@ -96,6 +98,7 @@ public NomadCloud( this.serverPassword = serverPassword; this.prune = prune; this.templates = Optional.ofNullable(templates).orElse(new ArrayList<>()); + this.dynamicTemplates = new ArrayList<>(); readResolve(); } @@ -172,15 +175,10 @@ private void pruneOrphanedWorkers(NomadWorkerTemplate template) { // Find the correct template for job public NomadWorkerTemplate getTemplate(Label label) { - for (NomadWorkerTemplate t : templates) { - if (label == null && !t.getLabels().isEmpty()) { - continue; - } - if ((label == null && t.getLabels().isEmpty()) || (label != null && label.matches(Label.parse(t.getLabels())))) { - return t; - } - } - return null; + return Stream.concat(templates.stream(), dynamicTemplates.stream()) + .filter(t -> label == null && t.getLabels().isEmpty() || (label != null && label.matches(Label.parse(t.getLabels())))) + .findFirst() + .orElse(null); } @Override @@ -249,6 +247,22 @@ public Secret getServerPassword() { return serverPassword; } + /** + * Adds a so called 'dynamic jobTemplate' to this cloud. It is pretty much the same as the jobTemplate defined via the cloud + * management, but it is not editable and not visible. It can only be accessed via the assigned label. + */ + public void addDynamicTemplate(NomadWorkerTemplate template) { + dynamicTemplates.add(template); + } + + /** + * Removes an existing so called 'dynamic jobTemplate'. + * @param label the label assigned to the 'dynamic jobTemplate' you want to remove + */ + public void removeDynamicTemplate(String label) { + dynamicTemplates.removeIf(t -> t.getLabels().equals(label)); + } + @Extension public static final class DescriptorImpl extends Descriptor { diff --git a/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStep.java b/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStep.java new file mode 100644 index 00000000..abd13db3 --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStep.java @@ -0,0 +1,100 @@ +package org.jenkinsci.plugins.nomad.pipeline; + +import java.io.Serializable; +import java.util.Collections; +import java.util.Set; + +import org.jenkinsci.plugins.workflow.steps.Step; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepDescriptor; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +import hudson.Extension; +import hudson.model.TaskListener; + +/** + * Provides the 'nomad' method for scripted pipelines.
+ * Parameters:
+ *
    + *
  • jobTemplate - Nomad Job Template (required, either in JSON or HCL)
  • + *
  • cloud - Name of the Nomad cloud configuration you want to use (Default: null, which means the first cloud is used)
  • + *
  • prefix - Node name is prefixed with this value (Default: jenkins-dt)
  • + *
  • remoteFS - Absolute path to the working directory of the remote agent (Default: null, which means hudson.model.slave.workspaceDir + * or JENKINS_HOME is used)
  • + *
+ */ +public class NomadPipelineStep extends Step implements Serializable { + + private static final long serialVersionUID = 1L; + + private final String jobTemplate; + private String cloud; + private String prefix; + private String remoteFS; + + @DataBoundConstructor + public NomadPipelineStep(String jobTemplate) { + this.jobTemplate = jobTemplate; + this.prefix = "jenkins-dt"; + } + + public String getJobTemplate() { + return jobTemplate; + } + + public String getCloud() { + return cloud; + } + + public String getRemoteFS() { + return remoteFS; + } + + public String getPrefix() { + return prefix; + } + + // === optional parameters start === + + @DataBoundSetter + public void setCloud(String cloud) { + this.cloud = cloud; + } + + @DataBoundSetter + public void setPrefix(String prefix) { + this.prefix = prefix; + } + + @DataBoundSetter + public void setRemoteFS(String remoteFS) { + this.remoteFS = remoteFS; + } + + // === optional parameters end === + + @Override + public StepExecution start(StepContext stepContext) { + return new NomadStepExecution(stepContext, this); + } + + @Extension + public static final class DescriptorImpl extends StepDescriptor { + @Override + public String getFunctionName() { + return "nomad"; + } + + @Override + public Set> getRequiredContext() { + return Collections.singleton(TaskListener.class); + } + + @Override + public boolean takesImplicitBlockArgument() { + return true; + } + } +} diff --git a/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadStepExecution.java b/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadStepExecution.java new file mode 100644 index 00000000..2caf822a --- /dev/null +++ b/src/main/java/org/jenkinsci/plugins/nomad/pipeline/NomadStepExecution.java @@ -0,0 +1,88 @@ +package org.jenkinsci.plugins.nomad.pipeline; + +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; +import java.util.UUID; + +import org.jenkinsci.plugins.nomad.NomadCloud; +import org.jenkinsci.plugins.nomad.NomadWorkerTemplate; +import org.jenkinsci.plugins.workflow.steps.BodyExecutionCallback; +import org.jenkinsci.plugins.workflow.steps.EnvironmentExpander; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepExecution; + +import hudson.AbortException; +import jenkins.model.Jenkins; + +/** + * Executes a given 'nomad' step for scripted pipelines. How it works:
+ *
    + *
  1. Resolve nomad cloud by either the specified name or use the first Nomad cloud
  2. + *
  3. Add the given job template with the cloud and assign a unique label to that template
  4. + *
  5. Define the environment variable JOB_LABEL with the corresponding label
  6. + *
  7. Execute the nomad step
  8. + *
  9. Remove the job template from the cloud
  10. + *
+ * Note: The job templates added by this step are not editable, and they are also not visible via cloud management. + */ +public class NomadStepExecution extends StepExecution { + + private final NomadPipelineStep step; + + public NomadStepExecution(StepContext stepContext, NomadPipelineStep step) { + super(stepContext); + this.step = step; + } + + @Override + public boolean start() throws IOException, InterruptedException { + NomadCloud cloud = resolveCloud(); + NomadWorkerTemplate template = new NomadWorkerTemplate( + step.getPrefix(), + UUID.randomUUID().toString(), + 0, + false, + 1, + step.getRemoteFS(), + step.getJobTemplate() + ); + + cloud.addDynamicTemplate(template); + + getContext().newBodyInvoker() + .withCallback(new RemoveTemplateCallBack(template.getLabels())) + .withContext(EnvironmentExpander.merge( + getContext().get(EnvironmentExpander.class), + EnvironmentExpander.constant(Collections.singletonMap("JOB_LABEL", template.getLabels())) + )) + .start(); + + return false; + } + + private NomadCloud resolveCloud() throws AbortException { + if (step.getCloud() == null) { + return Optional.ofNullable(Jenkins.get().clouds.get(NomadCloud.class)) + .orElseThrow(() -> new AbortException("No Nomad cloud was found. Please configure at least one!")); + } + return Optional.ofNullable(Jenkins.get().getCloud(step.getCloud())) + .filter(cloud -> cloud instanceof NomadCloud) + .map(cloud -> (NomadCloud) cloud) + .orElseThrow(() -> new AbortException(String.format("Nomad cloud does not exist: %s", step.getCloud()))); + } + + private class RemoveTemplateCallBack extends BodyExecutionCallback.TailCall { + private final String label; + + public RemoveTemplateCallBack(String label) { + this.label = label; + } + + @Override + protected void finished(StepContext stepContext) throws AbortException { + NomadCloud cloud = resolveCloud(); + cloud.removeDynamicTemplate(label); + } + } +} diff --git a/src/test/java/org/jenkinsci/plugins/nomad/NomadCloudTest.java b/src/test/java/org/jenkinsci/plugins/nomad/NomadCloudTest.java index 77d3e68e..897beb00 100644 --- a/src/test/java/org/jenkinsci/plugins/nomad/NomadCloudTest.java +++ b/src/test/java/org/jenkinsci/plugins/nomad/NomadCloudTest.java @@ -2,16 +2,18 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.Matchers.nullValue; import hudson.model.labels.LabelAtom; import hudson.slaves.NodeProvisioner; + import org.junit.Rule; import org.junit.Test; import org.jvnet.hudson.test.JenkinsRule; +import java.util.Arrays; import java.util.Collection; -import java.util.Collections; import java.util.UUID; public class NomadCloudTest { @@ -33,6 +35,21 @@ public void testCanProvision() { assertThat(result, is(true)); } + @Test + public void testCanProvisionDynamically() { + // GIVEN + LabelAtom label = createLabel(); + NomadCloud cloud = createCloud(); + NomadWorkerTemplate template = createTemplate(label.getName()); + cloud.addDynamicTemplate(template); + + // WHEN + boolean result = cloud.canProvision(label); + + // THEN + assertThat(result, is(true)); + } + @Test public void testProvision() { // GIVEN @@ -47,6 +64,21 @@ public void testProvision() { assertThat(result.size(), is(3)); } + @Test + public void testProvisionDynamically() { + // GIVEN + LabelAtom label = createLabel(); + NomadCloud cloud = createCloud(); + NomadWorkerTemplate template = createTemplate(label.getName()); + cloud.addDynamicTemplate(template); + + // WHEN + Collection result = cloud.provision(label, 1); + + // THEN + assertThat(result.size(), is(1)); + } + @Test public void testGetTemplateWithLabels() { // GIVEN @@ -100,10 +132,10 @@ public void testGetTemplateWithLabelNull() { NomadWorkerTemplate result = cloud.getTemplate(null); // THEN - assertThat(result, is(result)); + assertThat(result, notNullValue()); } - private NomadCloud createCloud(NomadWorkerTemplate template) { + private NomadCloud createCloud(NomadWorkerTemplate... template) { return new NomadCloud( "nomad", "nomadUrl", @@ -115,7 +147,7 @@ private NomadCloud createCloud(NomadWorkerTemplate template) { 1, "", false, - Collections.singletonList(template)); + template == null ? null : Arrays.asList(template)); } private NomadWorkerTemplate createTemplate(String labels) { diff --git a/src/test/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStepTest.java b/src/test/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStepTest.java new file mode 100644 index 00000000..850090e3 --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/nomad/pipeline/NomadPipelineStepTest.java @@ -0,0 +1,126 @@ +package org.jenkinsci.plugins.nomad.pipeline; + +import java.util.ArrayList; + +import org.jenkinsci.plugins.nomad.NomadCloud; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.ClassRule; +import org.junit.Rule; +import org.junit.Test; +import org.jvnet.hudson.test.BuildWatcher; +import org.jvnet.hudson.test.JenkinsRule; + +import hudson.model.Result; + +public class NomadPipelineStepTest { + + @Rule + public JenkinsRule j = new JenkinsRule(); + + @ClassRule + public static BuildWatcher buildWatcher = new BuildWatcher(); + + @Test + public void testNomadCloudDoesNotExist() throws Exception { + // GIVEN + + // WHEN + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("nomad (jobTemplate: '', cloud: 'aaaa') {\n" + + " node (JOB_LABEL) {\n" + + " sh hostname\n" + + " }\n" + + "}", true)); + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.waitForCompletion(b); + + // THEN + j.assertBuildStatus(Result.FAILURE, b); + j.assertLogContains("ERROR: Nomad cloud does not exist: aaaa", b); + } + + @Test + public void testNoNomadCloudFound() throws Exception { + // GIVEN + + // WHEN + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("nomad (jobTemplate: '') {\n" + + " node (JOB_LABEL) {\n" + + " sh hostname\n" + + " }\n" + + "}", true)); + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.waitForCompletion(b); + + // THEN + j.assertBuildStatus(Result.FAILURE, b); + j.assertLogContains("ERROR: No Nomad cloud was found. Please configure at least one!", b); + } + + @Test + public void testDefaultNomadCloudFound() throws Exception { + // GIVEN + NomadCloud cloud = new NomadCloud( + "aaa", + "http://localhost:4646", + false, + null, + null, + null, + null, + 1, + null, + false, + new ArrayList<>() + ); + j.jenkins.clouds.add(cloud); + j.jenkins.save(); + + // WHEN + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("nomad (jobTemplate: '') {\n" + + " print \"${JOB_LABEL == null ? 'JOB_LABEL null' : 'JOB_LABEL not null'}\" \n" + + "}", true)); + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.waitForCompletion(b); + + // THEN + j.assertBuildStatusSuccess(b); + j.assertLogContains("JOB_LABEL not null", b); + } + + @Test + public void testNamedNomadCloudFound() throws Exception { + // GIVEN + NomadCloud cloud = new NomadCloud( + "aaa", + "http://localhost:4646", + false, + null, + null, + null, + null, + 1, + null, + false, + new ArrayList<>() + ); + j.jenkins.clouds.add(cloud); + j.jenkins.save(); + + // WHEN + WorkflowJob p = j.jenkins.createProject(WorkflowJob.class, "p"); + p.setDefinition(new CpsFlowDefinition("nomad (jobTemplate: '', cloud: 'aaa') {\n" + + " print \"${JOB_LABEL == null ? 'JOB_LABEL null' : 'JOB_LABEL not null'}\" \n" + + "}", true)); + WorkflowRun b = p.scheduleBuild2(0).waitForStart(); + j.waitForCompletion(b); + + // THEN + j.assertBuildStatusSuccess(b); + j.assertLogContains("JOB_LABEL not null", b); + } +} \ No newline at end of file