[JENKINS-73835] Do not allow builds to be deleted while they are still running and ensure build discarders run after builds are fully complete (#9810)

* [JENKINS-73835] Do not allow builds to be deleted while they are still running

* [JENKINS-73835] Avoid redundant calls to Job.logRotate when builds complete and always call Job.logRotate after build finalization

* [JENKINS-73835] Add issue reference to RunTest.buildsMayNotBeDeletedWhileRunning

* [JENKINS-73835] Adjust DeleteBuildsCommandTest.deleteBuildsShouldSuccessEvenTheBuildIsRunning to match new behavior

* [JENKINS-73835] Run/delete.jelly should check Run.isLogUpdated, not Run.isBuilding
This commit is contained in:
Devin Nusbaum 2024-10-12 08:06:18 -04:00 committed by GitHub
parent 925de03663
commit d34b17ee4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 64 additions and 26 deletions

View File

@ -1551,6 +1551,9 @@ public abstract class Run<JobT extends Job<JobT, RunT>, RunT extends Run<JobT, R
* if we fail to delete. * if we fail to delete.
*/ */
public void delete() throws IOException { public void delete() throws IOException {
if (isLogUpdated()) {
throw new IOException("Unable to delete " + this + " because it is still running");
}
synchronized (this) { synchronized (this) {
// Avoid concurrent delete. See https://issues.jenkins.io/browse/JENKINS-61687 // Avoid concurrent delete. See https://issues.jenkins.io/browse/JENKINS-61687
if (isPendingDelete) { if (isPendingDelete) {
@ -1885,12 +1888,6 @@ public abstract class Run<JobT extends Job<JobT, RunT>, RunT extends Run<JobT, R
LOGGER.log(Level.SEVERE, "Failed to save build record", e); LOGGER.log(Level.SEVERE, "Failed to save build record", e);
} }
} }
try {
getParent().logRotate();
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "Failed to rotate log", e);
}
} finally { } finally {
onEndBuilding(); onEndBuilding();
if (logger != null) { if (logger != null) {

View File

@ -250,7 +250,7 @@ public class LogRotator extends BuildDiscarder {
LOGGER.log(FINER, "{0} is not to be removed or purged of artifacts because its the last stable build", r); LOGGER.log(FINER, "{0} is not to be removed or purged of artifacts because its the last stable build", r);
return true; return true;
} }
if (r.isBuilding()) { if (r.isLogUpdated()) {
LOGGER.log(FINER, "{0} is not to be removed or purged of artifacts because its still building", r); LOGGER.log(FINER, "{0} is not to be removed or purged of artifacts because its still building", r);
return true; return true;
} }

View File

@ -31,6 +31,7 @@ import hudson.model.TaskListener;
import java.io.IOException; import java.io.IOException;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import java.util.stream.Stream;
import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.accmod.restrictions.NoExternalUse;
@ -56,8 +57,18 @@ public class BackgroundGlobalBuildDiscarder extends AsyncPeriodicWork {
} }
} }
/**
* Runs all globally configured build discarders against a job.
*/
public static void processJob(TaskListener listener, Job job) { public static void processJob(TaskListener listener, Job job) {
GlobalBuildDiscarderConfiguration.get().getConfiguredBuildDiscarders().forEach(strategy -> { processJob(listener, job, GlobalBuildDiscarderConfiguration.get().getConfiguredBuildDiscarders().stream());
}
/**
* Runs the specified build discarders against a job.
*/
public static void processJob(TaskListener listener, Job job, Stream<GlobalBuildDiscarderStrategy> strategies) {
strategies.forEach(strategy -> {
String displayName = strategy.getDescriptor().getDisplayName(); String displayName = strategy.getDescriptor().getDisplayName();
if (strategy.isApplicable(job)) { if (strategy.isApplicable(job)) {
try { try {

View File

@ -35,7 +35,7 @@ import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse; import org.kohsuke.accmod.restrictions.NoExternalUse;
/** /**
* Run background build discarders on an individual job once a build is finalized * Run build discarders on an individual job once a build is finalized
*/ */
@Extension @Extension
@Restricted(NoExternalUse.class) @Restricted(NoExternalUse.class)
@ -46,6 +46,15 @@ public class GlobalBuildDiscarderListener extends RunListener<Run> {
@Override @Override
public void onFinalized(Run run) { public void onFinalized(Run run) {
Job job = run.getParent(); Job job = run.getParent();
BackgroundGlobalBuildDiscarder.processJob(new LogTaskListener(LOGGER, Level.FINE), job); try {
// Job-level build discarder execution is unconditional.
job.logRotate();
} catch (Exception e) {
LOGGER.log(Level.WARNING, e, () -> "Failed to rotate log for " + run);
}
// Avoid calling Job.logRotate twice in case JobGlobalBuildDiscarderStrategy is configured globally.
BackgroundGlobalBuildDiscarder.processJob(new LogTaskListener(LOGGER, Level.FINE), job,
GlobalBuildDiscarderConfiguration.get().getConfiguredBuildDiscarders().stream()
.filter(s -> !(s instanceof JobGlobalBuildDiscarderStrategy)));
} }
} }

View File

@ -27,7 +27,7 @@ THE SOFTWARE.
--> -->
<?jelly escape-by-default='true'?> <?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout"> <j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout">
<j:if test="${!it.building and !it.keepLog}"> <j:if test="${!it.logUpdated and !it.keepLog}">
<l:task href="${buildUrl.baseUrl}/confirmDelete" icon="icon-edit-delete icon-md" permission="${it.DELETE}" title="${%delete.build(it.displayName)}"/> <l:task href="${buildUrl.baseUrl}/confirmDelete" icon="icon-edit-delete icon-md" permission="${it.DELETE}" title="${%delete.build(it.displayName)}"/>
</j:if> </j:if>
</j:jelly> </j:jelly>

View File

@ -32,21 +32,18 @@ import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize; import static org.hamcrest.Matchers.hasSize;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assume.assumeFalse;
import hudson.Functions;
import hudson.model.ExecutorTest; import hudson.model.ExecutorTest;
import hudson.model.FreeStyleProject; import hudson.model.FreeStyleProject;
import hudson.model.Item; import hudson.model.Item;
import hudson.model.Run; import hudson.model.Run;
import hudson.model.labels.LabelAtom; import hudson.model.labels.LabelAtom;
import hudson.tasks.Shell; import hudson.tasks.Shell;
import java.io.IOException;
import jenkins.model.Jenkins; import jenkins.model.Jenkins;
import org.junit.AssumptionViolatedException;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule;
/** /**
@ -139,8 +136,8 @@ public class DeleteBuildsCommandTest {
assertThat(result.stdout(), containsString("Deleted 0 builds")); assertThat(result.stdout(), containsString("Deleted 0 builds"));
} }
@Test public void deleteBuildsShouldSuccessEvenTheBuildIsRunning() throws Exception { @Issue("JENKINS-73835")
assumeFalse("You can't delete files that are in use on Windows", Functions.isWindows()); @Test public void deleteBuildsShouldFailIfTheBuildIsRunning() throws Exception {
FreeStyleProject project = j.createFreeStyleProject("aProject"); FreeStyleProject project = j.createFreeStyleProject("aProject");
ExecutorTest.startBlockingBuild(project); ExecutorTest.startBlockingBuild(project);
assertThat(((FreeStyleProject) j.jenkins.getItem("aProject")).getBuilds(), hasSize(1)); assertThat(((FreeStyleProject) j.jenkins.getItem("aProject")).getBuilds(), hasSize(1));
@ -148,15 +145,9 @@ public class DeleteBuildsCommandTest {
final CLICommandInvoker.Result result = command final CLICommandInvoker.Result result = command
.authorizedTo(Jenkins.READ, Item.READ, Run.DELETE) .authorizedTo(Jenkins.READ, Item.READ, Run.DELETE)
.invokeWithArgs("aProject", "1"); .invokeWithArgs("aProject", "1");
assertThat(result, succeeded()); assertThat(result, failedWith(1));
assertThat(result.stdout(), containsString("Deleted 1 builds")); assertThat(result, hasNoStandardOutput());
assertThat(((FreeStyleProject) j.jenkins.getItem("aProject")).getBuilds(), hasSize(0)); assertThat(result.stderr(), containsString("Unable to delete aProject #1 because it is still running"));
assertThat(project.isBuilding(), equalTo(false));
try {
project.delete();
} catch (IOException | InterruptedException x) {
throw new AssumptionViolatedException("Could not delete test project (race condition?)", x);
}
} }
@Test public void deleteBuildsShouldSuccessEvenTheBuildIsStuckInTheQueue() throws Exception { @Test public void deleteBuildsShouldSuccessEvenTheBuildIsStuckInTheQueue() throws Exception {

View File

@ -30,6 +30,7 @@ import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import hudson.ExtensionList; import hudson.ExtensionList;
@ -62,6 +63,7 @@ import org.junit.Test;
import org.junit.experimental.categories.Category; import org.junit.experimental.categories.Category;
import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule;
import org.jvnet.hudson.test.SleepBuilder;
import org.jvnet.hudson.test.SmokeTest; import org.jvnet.hudson.test.SmokeTest;
import org.jvnet.hudson.test.TestExtension; import org.jvnet.hudson.test.TestExtension;
import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundConstructor;
@ -128,6 +130,18 @@ public class RunTest {
} }
} }
@Issue("JENKINS-73835")
@Test public void buildsMayNotBeDeletedWhileRunning() throws Exception {
var p = j.createFreeStyleProject();
p.getBuildersList().add(new SleepBuilder(999999));
var b = p.scheduleBuild2(0).waitForStart();
var ex = assertThrows(IOException.class, () -> b.delete());
assertThat(ex.getMessage(), containsString("Unable to delete " + b + " because it is still running"));
b.getExecutor().interrupt();
j.waitForCompletion(b);
b.delete(); // Works fine.
}
@Issue("SECURITY-1902") @Issue("SECURITY-1902")
@Test public void preventXssInBadgeTooltip() throws Exception { @Test public void preventXssInBadgeTooltip() throws Exception {
j.jenkins.setQuietPeriod(0); j.jenkins.setQuietPeriod(0);

View File

@ -50,8 +50,10 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException; import java.util.concurrent.TimeoutException;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.junit.ClassRule;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.jvnet.hudson.test.BuildWatcher;
import org.jvnet.hudson.test.FailureBuilder; import org.jvnet.hudson.test.FailureBuilder;
import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.Issue;
import org.jvnet.hudson.test.JenkinsRule; import org.jvnet.hudson.test.JenkinsRule;
@ -62,6 +64,9 @@ import org.jvnet.hudson.test.TestBuilder;
*/ */
public class LogRotatorTest { public class LogRotatorTest {
@ClassRule
public static BuildWatcher watcher = new BuildWatcher();
@Rule @Rule
public JenkinsRule j = new JenkinsRule(); public JenkinsRule j = new JenkinsRule();
@ -96,6 +101,17 @@ public class LogRotatorTest {
assertEquals(2, numberOf(project.getLastFailedBuild())); assertEquals(2, numberOf(project.getLastFailedBuild()));
} }
@Test
public void ableToDeleteCurrentBuild() throws Exception {
var p = j.createFreeStyleProject();
// Keep 0 builds, i.e. immediately delete builds as they complete.
LogRotator logRotator = new LogRotator(-1, 0, -1, -1);
logRotator.setRemoveLastBuild(true);
p.setBuildDiscarder(logRotator);
j.buildAndAssertStatus(Result.SUCCESS, p);
assertNull(p.getBuildByNumber(1));
}
@Test @Test
@Issue("JENKINS-2417") @Issue("JENKINS-2417")
public void stableVsUnstable() throws Exception { public void stableVsUnstable() throws Exception {