Skip to content
This repository has been archived by the owner on Dec 15, 2021. It is now read-only.

Commit

Permalink
Merge pull request #334 from jglick/rerun
Browse files Browse the repository at this point in the history
[JENKINS-32727] Rerun a build with script edits
  • Loading branch information
jglick committed Feb 24, 2016
2 parents a05eb6a + c6416e0 commit a62e52a
Show file tree
Hide file tree
Showing 26 changed files with 1,360 additions and 129 deletions.
1 change: 1 addition & 0 deletions CHANGES.md
Expand Up @@ -4,6 +4,7 @@ Only noting significant user changes, not internal code cleanups and minor bug f

## 1.14 (upcoming)

* [JENKINS-32727](https://issues.jenkins-ci.org/browse/JENKINS-32727): new facility to replay Pipeline builds with a modified script.
* Simple `git` step now checks out a branch, not a detached head, for ease of committing to the workspace.
* [JENKINS-33005](https://issues.jenkins-ci.org/browse/JENKINS-33005): hang running `stage` step which tries to cancel an earlier build that could not be loaded.
* [JENKINS-32214](https://issues.jenkins-ci.org/browse/JENKINS-32214): polling for Subversion and Mercurial did not take into account changes already checked out in a running build.
Expand Down
Expand Up @@ -34,6 +34,7 @@
import java.util.ListIterator;
import org.codehaus.groovy.transform.ASTTransformationVisitor;
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution;
import org.jenkinsci.plugins.workflow.cps.CpsStepContext;
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
import org.jenkinsci.plugins.workflow.job.WorkflowJob;
Expand Down Expand Up @@ -120,7 +121,7 @@ private static void assertGC(WeakReference<?> reference) throws Exception {
WorkflowJob p = story.j.jenkins.createProject(WorkflowJob.class, "p");
p.setDefinition(new CpsFlowDefinition(
"echo 'a step'; semaphore 'one'; retry(2) {semaphore 'two'; node {semaphore 'three'}; semaphore 'four'}; semaphore 'five'; " +
"parallel a: {node {semaphore 'six'}}, b: {semaphore 'seven'}; semaphore 'eight'"));
"parallel a: {node {semaphore 'six'}}, b: {semaphore 'seven'}; semaphore 'eight'", true));
WorkflowRun b = p.scheduleBuild2(0).waitForStart();
SemaphoreStep.waitForStart("one/1", b);
FlowExecution e = b.getExecution();
Expand All @@ -137,7 +138,8 @@ private static void assertGC(WeakReference<?> reference) throws Exception {
@Override public void evaluate() throws Throwable {
WorkflowJob p = story.j.jenkins.getItemByFullName("p", WorkflowJob.class);
WorkflowRun b = p.getLastBuild();
FlowExecution e = b.getExecution();
CpsFlowExecution e = (CpsFlowExecution) b.getExecution();
assertTrue(e.isSandbox());
SemaphoreStep.success("three/1", null);
SemaphoreStep.waitForStart("four/1", b);
assertStepExecutions(e, "retry {}", "semaphore");
Expand Down

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions cps/pom.xml
Expand Up @@ -62,6 +62,10 @@
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>script-security</artifactId>
</dependency>
<dependency>
<groupId>org.jenkins-ci.plugins</groupId>
<artifactId>scm-api</artifactId>
</dependency>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>workflow-support</artifactId>
Expand Down Expand Up @@ -90,6 +94,17 @@
<artifactId>ace-editor</artifactId>
<version>1.0.1</version>
</dependency>
<dependency>
<groupId>com.cloudbees</groupId>
<artifactId>diff4j</artifactId>
<version>1.2</version>
<exclusions>
<exclusion>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<resources>
Expand Down
Expand Up @@ -33,6 +33,7 @@
import com.cloudbees.groovy.cps.sandbox.DefaultInvoker;
import com.cloudbees.groovy.cps.sandbox.SandboxInvoker;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
Expand Down Expand Up @@ -275,7 +276,7 @@ public CpsFlowExecution(String script, FlowExecutionOwner owner) throws IOExcept
this(script, false, owner);
}

protected CpsFlowExecution(String script, boolean sandbox, FlowExecutionOwner owner) throws IOException {
public CpsFlowExecution(String script, boolean sandbox, FlowExecutionOwner owner) throws IOException {
this.owner = owner;
this.script = script;
this.sandbox = sandbox;
Expand Down Expand Up @@ -305,6 +306,14 @@ public GroovyShell getShell() {
public FlowNodeStorage getStorage() {
return storage;
}

public String getScript() {
return script;
}

public Map<String,String> getLoadedScripts() {
return ImmutableMap.copyOf(loadedScripts);
}

/**
* True if executing with groovy-sandbox, false if executing with approval.
Expand Down Expand Up @@ -862,6 +871,15 @@ void notifyListeners(List<FlowNode> nodes, boolean synchronous) {
}
}

/**
* Finds the expected next loaded script name, like {@code Script1}.
* @param path a file path being loaded (currently ignored)
*/
@Restricted(NoExternalUse.class)
public String getNextScriptName(String path) {
return shell.generateScriptName().replaceFirst("[.]groovy$", "");
}

@Override public String toString() {
return "CpsFlowExecution[" + owner + "]";
}
Expand Down Expand Up @@ -905,6 +923,7 @@ public void marshal(Object source, HierarchicalStreamWriter w, MarshallingContex
writeChild(w, context, "result", e.result, Result.class);
writeChild(w, context, "script", e.script, String.class);
writeChild(w, context, "loadedScripts", e.loadedScripts, Map.class);
writeChild(w, context, "sandbox", e.sandbox, Boolean.class);
if (e.user != null) {
writeChild(w, context, "user", e.user, String.class);
}
Expand Down Expand Up @@ -958,6 +977,10 @@ public Object unmarshal(HierarchicalStreamReader reader, final UnmarshallingCont
Map loadedScripts = readChild(reader, context, Map.class, result);
setField(result, "loadedScripts", loadedScripts);
} else
if (nodeName.equals("sandbox")) {
boolean sandbox = readChild(reader, context, Boolean.class, result);
setField(result, "sandbox", sandbox);
} else
if (nodeName.equals("owner")) {
readChild(reader, context, Object.class, result); // for compatibility; discarded
} else
Expand Down
@@ -0,0 +1,250 @@
/*
* The MIT License
*
* Copyright 2016 CloudBees, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/

package org.jenkinsci.plugins.workflow.cps.replay;

import com.cloudbees.diff.Diff;
import com.google.common.collect.ImmutableList;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import hudson.Extension;
import hudson.Functions;
import hudson.init.InitMilestone;
import hudson.init.Initializer;
import hudson.model.Action;
import hudson.model.Cause;
import hudson.model.CauseAction;
import hudson.model.Item;
import hudson.model.Job;
import hudson.model.ParametersAction;
import hudson.model.Run;
import hudson.model.queue.QueueTaskFuture;
import hudson.security.Permission;
import hudson.security.PermissionScope;
import hudson.util.FormValidation;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.annotation.CheckForNull;
import javax.annotation.Nonnull;
import javax.servlet.ServletException;
import jenkins.model.Jenkins;
import jenkins.model.ParameterizedJobMixIn;
import jenkins.model.TransientActionFactory;
import jenkins.scm.api.SCMRevisionAction;
import net.sf.json.JSON;
import net.sf.json.JSONObject;
import org.acegisecurity.AccessDeniedException;
import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition;
import org.jenkinsci.plugins.workflow.cps.CpsFlowExecution;
import org.jenkinsci.plugins.workflow.flow.FlowExecution;
import org.jenkinsci.plugins.workflow.flow.FlowExecutionOwner;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.DoNotUse;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.interceptor.RequirePOST;

/**
* Attached to a {@link Run} when it could be replayed with script edits.
*/
@SuppressWarnings("rawtypes") // on Run
public class ReplayAction implements Action {

private final Run run;

private ReplayAction(Run run) {
this.run = run;
}

@Override public String getDisplayName() {
return "Replay";
}

@Override public String getIconFileName() {
return isEnabled() ? "redo.png" : null;
}

@Override public String getUrlName() {
return isEnabled() ? "replay" : null;
}

private @CheckForNull CpsFlowExecution getExecution() {
FlowExecutionOwner owner = ((FlowExecutionOwner.Executable) run).asFlowExecutionOwner();
if (owner == null) {
return null;
}
FlowExecution exec = owner.getOrNull();
return exec instanceof CpsFlowExecution ? (CpsFlowExecution) exec : null;
}

/* accessible to Jelly */ public boolean isEnabled() {
if (!run.hasPermission(REPLAY)) {
return false;
}
CpsFlowExecution exec = getExecution();
if (exec == null) {
return false;
}
if (exec.isSandbox()) {
return true;
} else {
// Whole-script approval mode. Can we submit an arbitrary script right here?
return Jenkins.getActiveInstance().hasPermission(Jenkins.RUN_SCRIPTS);
}
}

/** @see CpsFlowExecution#getScript */
/* accessible to Jelly */ public String getOriginalScript() {
CpsFlowExecution execution = getExecution();
return execution != null ? execution.getScript() : "???";
}

/** @see CpsFlowExecution#getLoadedScripts */
/* accessible to Jelly */ public Map<String,String> getOriginalLoadedScripts() {
CpsFlowExecution execution = getExecution();
return execution != null ? execution.getLoadedScripts() : /* ? */Collections.<String,String>emptyMap();
}

/* accessible to Jelly */ public Run getOwner() {
return run;
}

@Restricted(DoNotUse.class)
@RequirePOST
public void doRun(StaplerRequest req, StaplerResponse rsp) throws ServletException, IOException {
if (!isEnabled()) {
throw new AccessDeniedException("not allowed to replay"); // AccessDeniedException2 requires us to look up the specific Permission
}
JSONObject form = req.getSubmittedForm();
// Copy originalLoadedScripts, replacing values with those from the form wherever defined.
Map<String,String> replacementLoadedScripts = new HashMap<String,String>();
for (Map.Entry<String,String> entry : getOriginalLoadedScripts().entrySet()) {
// optString since you might be replaying a running build, which might have loaded a script after the page load but before submission.
replacementLoadedScripts.put(entry.getKey(), form.optString(entry.getKey(), entry.getValue()));
}
run(form.getString("mainScript"), replacementLoadedScripts);
rsp.sendRedirect("../.."); // back to WorkflowJob; new build might not start instantly so cannot redirect to it
}

private static final Iterable<Class<? extends Action>> COPIED_ACTIONS = ImmutableList.of(
ParametersAction.class,
SCMRevisionAction.class
);

/**
* For whitebox testing.
* @param replacementMainScript main script; replacement for {@link #getOriginalScript}
* @param replacementLoadedScripts auxiliary scripts, keyed by class name; replacement for {@link #getOriginalLoadedScripts}
* @return a way to wait for the replayed build to complete
*/
public @CheckForNull QueueTaskFuture/*<Run>*/ run(@Nonnull String replacementMainScript, @Nonnull Map<String,String> replacementLoadedScripts) {
List<Action> actions = new ArrayList<Action>();
CpsFlowExecution execution = getExecution();
if (execution == null) {
return null;
}
actions.add(new ReplayFlowFactoryAction(replacementMainScript, replacementLoadedScripts, execution.isSandbox()));
actions.add(new CauseAction(new Cause.UserIdCause(), new ReplayCause(run)));
for (Class<? extends Action> c : COPIED_ACTIONS) {
actions.addAll(run.getActions(c));
}
return new ParameterizedJobMixIn() {
@Override protected Job asJob() {
return run.getParent();
}
}.scheduleBuild2(0, actions.toArray(new Action[actions.size()]));
}

public String getDiff() {
Run<?,?> original = run;
ReplayCause cause;
while ((cause = original.getCause(ReplayCause.class)) != null) {
Run<?,?> earlier = cause.getOriginal();
if (earlier == null) {
// Deleted? Oh well.
break;
}
original = earlier;
}
ReplayAction originalAction = original.getAction(ReplayAction.class);
if (originalAction == null) {
return "???";
}
try {
StringBuilder diff = new StringBuilder(diff(/* TODO JENKINS-31838 */"Jenkinsfile", originalAction.getOriginalScript(), getOriginalScript()));
Map<String,String> originalLoadedScripts = originalAction.getOriginalLoadedScripts();
for (Map.Entry<String,String> entry : getOriginalLoadedScripts().entrySet()) {
String script = entry.getKey();
String originalScript = originalLoadedScripts.get(script);
if (originalScript != null) {
diff.append(diff(script, originalScript, entry.getValue()));
}
}
return diff.toString();
} catch (IOException x) {
return Functions.printThrowable(x);
}
}
private static String diff(String script, String oldText, String nueText) throws IOException {
Diff hunks = Diff.diff(new StringReader(oldText), new StringReader(nueText), false);
// TODO rather than old vs. new could use (e.g.) build-10 vs. build-13
return hunks.isEmpty() ? "" : hunks.toUnifiedDiff("old/" + script, "new/" + script, new StringReader(oldText), new StringReader(nueText), 3);
}

// Stub, we do not need to do anything here.
public FormValidation doCheckScript() {
return FormValidation.ok();
}

public JSON doCheckScriptCompile(@QueryParameter String value) {
return Jenkins.getActiveInstance().getDescriptorByType(CpsFlowDefinition.DescriptorImpl.class).doCheckScriptCompile(value);
}

public static final Permission REPLAY = new Permission(Run.PERMISSIONS, "Replay", Messages._Replay_permission_description(), Item.CONFIGURE, PermissionScope.RUN);

@SuppressFBWarnings(value="RV_RETURN_VALUE_IGNORED_NO_SIDE_EFFECT", justification="getEnabled return value discarded")
@Initializer(after=InitMilestone.PLUGINS_STARTED, before=InitMilestone.EXTENSIONS_AUGMENTED)
public static void ensurePermissionRegistered() {
REPLAY.getEnabled();
}

@Extension public static class Factory extends TransientActionFactory<Run> {

@Override public Class<Run> type() {
return Run.class;
}

@Override public Collection<? extends Action> createFor(Run run) {
return run instanceof FlowExecutionOwner.Executable && run.getParent() instanceof ParameterizedJobMixIn.ParameterizedJob ? Collections.<Action>singleton(new ReplayAction(run)) : Collections.<Action>emptySet();
}

}

}

0 comments on commit a62e52a

Please sign in to comment.