Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 47 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
# Schedule Build plugin

Adds capability to schedule a build for a later point in time. Asks the
user for a date and time and adds the build to the build queue with the
respective quiet period.
Adds capability to schedule a build for a later point in time by asking for date and
time and parameters if applicable.
The plugin offers 2 ways how to schedule the build.
- Adding the run to the queue immediately with a quiet period calculated from the selected date and time.
- Using cron-like scheduling to plan the build for a specific date and time. This mode offers additional features.

## Scheduling Builds

Expand All @@ -14,29 +16,61 @@ or use the schedule build action in the list view.

![](docs/images/Schedule_Action.png)

Then select date and time when to schedule the build.
Then select date and time when to schedule the build and set options and parameters.

![](docs/images/Schedule_Page.png)

The build will be added to the build queue with the respective quiet
If selected the build will be added to the build queue with the respective quiet
period.

![](docs/images/Schedule_Build_Queue.png)

## Scheduling parameterized jobs
Alternatively, if the "Use cron-like scheduling" option is selected:
![](docs/images/Planned_Builds.png)

Parameterized jobs can also be scheduled with the plugin.
The parameter page for the job is displayed to the user immediately after the "Schedule" button is pressed.
Once the parameter values are selected, the job will be scheduled.

## Scheduling via Queue

To schedule a build via the queue, simply select the desired date and time, don't check `Schedule via Cron`
and set any parameters if applicable.
When pressing the `Schedule` button, the build will be added to the queue with a quiet period
calculated from the selected date and time. Due to Jenkins deduplicating builds in the queue, if a build for
the same job is added with identical parameters, the existing build in the queue will be updated to the new
quiet period instead of adding a new build to the queue. That means if you start the build with `Build Now`
before the scheduled time, the scheduled build will be removed from the queue and the job will run
immediately. This also means that the job will start to run immediately after a Jenkins restart when Jenkins
was down at the scheduled time.

## Scheduling via Cron

To schedule a build via cron, simply select the desired date and time, check `Schedule via Cron`
and set any parameters if applicable.

When pressing the `Schedule` button, the build will be added to the list of planned builds for the job. At the scheduled date
and time, a new build will be created with the selected parameters.

Multiple builds can be scheduled for the same job with identical parameters at different times when they at least differ by a second.
The scheduled builds are persistent and will survive Jenkins restarts.

If a build is missed because Jenkins was down, it will be started immediately if the option `Trigger
on missed` is set. Without that option, the build will be skipped. There is a configurable grace
period to still trigger the build after the scheduled time.

Builds scheduled via cron have a dedicated Cause.

Renames or moves of jobs are supported. The scheduled builds will follow the job.

The `Planned Builds` view offers the ability to cancel scheduled builds.

## Configure Schedule Build Plugin

The configuration of the schedule build plugin is very simple. There are
only two parameters on the Jenkins system configuration page.
only three parameters on the Jenkins system configuration page.

The default time which is set when a user wants to schedule a build may
be configured and time zone used by the plugin, which might differ from
the system time zone.
be configured, time zone used by the plugin, which might differ from
the system time zone and the grace period for missed builds even when `Trigger on missed`
is not set.

![](docs/images/Schedule_Timezone.png)

Expand All @@ -50,6 +84,7 @@ unclassified:
scheduleBuild:
defaultStartTime: "11:00:00 PM"
timeZone: "Europe/Paris"
gracePeriodMinutes: 3
```

## Release Notes
Expand Down
Binary file added docs/images/Planned_Builds.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Schedule_Action.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Schedule_Build_Queue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Schedule_Page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Schedule_Project_Page.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/Schedule_Timezone.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,32 +1,41 @@
package org.jenkinsci.plugins.schedulebuild;

import hudson.model.Action;
import hudson.model.Descriptor.FormException;
import hudson.model.Item;
import hudson.model.Job;
import hudson.model.ParameterDefinition;
import hudson.model.ParameterValue;
import hudson.model.ParametersDefinitionProperty;
import hudson.util.FormValidation;
import jakarta.servlet.ServletException;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;
import jenkins.management.Badge;
import jenkins.model.ParameterizedJobMixIn;
import jenkins.util.TimeDuration;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.jenkins.ui.icon.IconSpec;
import org.kohsuke.accmod.Restricted;
import org.kohsuke.accmod.restrictions.NoExternalUse;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.HttpResponse;
import org.kohsuke.stapler.HttpResponses;
import org.kohsuke.stapler.QueryParameter;
import org.kohsuke.stapler.StaplerProxy;
import org.kohsuke.stapler.StaplerRequest2;
import org.kohsuke.stapler.StaplerResponse2;
import org.kohsuke.stapler.interceptor.RequirePOST;
import org.kohsuke.stapler.verb.POST;

public class ScheduleBuildAction implements Action, StaplerProxy, IconSpec {
public class ScheduleBuildAction implements Action, IconSpec {

private static final Logger LOGGER = Logger.getLogger(ScheduleBuildAction.class.getName());

Expand Down Expand Up @@ -56,33 +65,19 @@ public String getIconFileName() {

@Override
public String getIconClassName() {
return target.hasPermission(Job.BUILD) && this.target.isBuildable()
? "symbol-calendar-outline plugin-ionicons-api"
: null;
return "symbol-calendar-outline plugin-ionicons-api";
}

@Override
public String getDisplayName() {
return target.hasPermission(Job.BUILD) && this.target.isBuildable()
? Messages.ScheduleBuildAction_DisplayName()
: null;
return Messages.ScheduleBuildAction_DisplayName();
}

@Override
public String getUrlName() {
return "schedule";
}

public boolean schedule(StaplerRequest2 req, JSONObject formData) throws FormException {
return true;
}

@Override
public Object getTarget() {
target.checkPermission(Job.BUILD);
return this;
}

public String getDefaultDate() {
return getDefaultDateObject().format(DateTimeFormatter.ofPattern(DATE_TIME_PATTERN));
}
Expand Down Expand Up @@ -130,30 +125,140 @@ public long getQuietPeriodInSeconds() {
return quietperiod;
}

@RequirePOST
public HttpResponse doNext(@QueryParameter String date, @AncestorInPath Item item) {
if (item == null) {
return FormValidation.ok();
public Badge getBadge() {
List<ScheduledBuild> plannedBuilds = getPlannedBuilds();
if (plannedBuilds.isEmpty()) {
return null;
}
// User requesting a build needs permission to start the build
item.checkPermission(Item.BUILD);
ZonedDateTime ddate, now = ZonedDateTime.now();
return new Badge(
Integer.toString(plannedBuilds.size()),
Messages.ScheduleBuildAction_BadgeTooltip(plannedBuilds.size()),
Badge.Severity.INFO);
}

final String time = date.trim();
@POST
public void doCancelBuild(@QueryParameter String id, StaplerResponse2 rsp) {
target.checkPermission(Item.CANCEL);
try {
ddate = parseDateTime(time)
.atZone(ScheduleBuildGlobalConfiguration.get().getZoneId());
} catch (DateTimeParseException ex) {
LOGGER.log(Level.INFO, ex, () -> "Error parsing " + time);
return HttpResponses.redirectTo("error");
for (ScheduledBuild sr : ScheduledBuildManager.getPlannedBuildsForJob(target.getFullName())) {
if (sr.getId().equals(id)) {
sr.setAborted(true);
ScheduledBuildManager.removeScheduledBuild(sr);
break;
}
}
if (ScheduledBuildManager.hasPlannedBuildsForJob(target.getFullName())) {
rsp.sendRedirect2("./planned");
} else {
rsp.sendRedirect2("..");
}
} catch (Exception e) {
throw new RuntimeException(e);
}
quietperiod = ChronoUnit.SECONDS.between(now, ddate);
LOGGER.log(Level.FINER, () -> "Quietperiod: " + quietperiod);
if (quietperiod + ScheduleBuildAction.SECURITY_MARGIN < 0) { // 120 sec security margin
LOGGER.log(Level.INFO, () -> "Error security margin" + quietperiod);
return HttpResponses.redirectTo("error");
}

@POST
public void doBuild(StaplerRequest2 req, StaplerResponse2 rsp) {
target.checkPermission(Item.BUILD);
try {
JSONObject formData = req.getSubmittedForm();
String date = formData.getString("date");
boolean scheduleViaCron = formData.has("scheduleViaCron");

ZonedDateTime startDateTime, now = ZonedDateTime.now();

final String time = date.trim();
try {
startDateTime = parseDateTime(time)
.atZone(ScheduleBuildGlobalConfiguration.get().getZoneId());
} catch (DateTimeParseException ex) {
LOGGER.log(Level.INFO, ex, () -> "Error parsing " + time);
rsp.sendRedirect2("error");
return;
}

long delay = ChronoUnit.SECONDS.between(now, startDateTime);
LOGGER.log(Level.FINER, () -> "Quietperiod: " + delay);
if (delay + ScheduleBuildAction.SECURITY_MARGIN < 0) { // 120 sec security margin
LOGGER.log(Level.INFO, () -> "Error security margin " + delay);
rsp.sendRedirect2("error");
return;
}

if (!scheduleViaCron) {
TimeDuration quietPeriod = new TimeDuration(Math.max(0, delay) * 1000);
new ParameterizedJobMixIn() {
@Override
protected Job<?, ?> asJob() {
return target;
}
}.doBuild(req, rsp, quietPeriod);
return;
}
String id = UUID.randomUUID().toString();
List<ParameterValue> values = new ArrayList<>();
JSONObject scheduleViaCronObject = formData.getJSONObject("scheduleViaCron");
boolean triggerOnMissed = scheduleViaCronObject.getBoolean("triggerOnMissed");

if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
if (pj.isParameterized()) {

Object parameter = formData.get("parameter");

if (parameter != null) {
ParametersDefinitionProperty prop = target.getProperty(ParametersDefinitionProperty.class);
JSONArray a = JSONArray.fromObject(parameter);

for (Object o : a) {
JSONObject jo = (JSONObject) o;
String name = jo.getString("name");

ParameterDefinition d = prop.getParameterDefinition(name);
if (d == null) throw new IllegalArgumentException("No such parameter definition: " + name);
ParameterValue parameterValue = d.createValue(req, jo);
if (parameterValue != null) {
values.add(parameterValue);
} else {
throw new IllegalArgumentException("Cannot retrieve the parameter value: " + name);
}
}
}
}
}

ScheduledBuild sr = new ScheduledBuild(
id, target.getFullName(), startDateTime, values, triggerOnMissed, new ScheduledBuildCause());

ScheduledBuildManager.addScheduledBuild(sr);

rsp.sendRedirect2("..");
} catch (IOException | ServletException e) {
throw new RuntimeException(e);
}
return HttpResponses.forwardToView(this, "redirect");
}

public boolean hasPlannedBuilds() {
return ScheduledBuildManager.hasPlannedBuildsForJob(target.getFullName());
}

public List<ScheduledBuild> getPlannedBuilds() {
return ScheduledBuildManager.getPlannedBuildsForJob(target.getFullName());
}

public boolean isParameterized() {
if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
return pj.isParameterized();
}
return false;
}

public List<ParameterDefinition> getParameterDefinitions() {
if (target instanceof ParameterizedJobMixIn.ParameterizedJob<?, ?> pj) {
if (pj.isParameterized()) {
return target.getProperty(ParametersDefinitionProperty.class).getParameterDefinitions();
}
}
return List.of();
}

private LocalDateTime parseDateTime(String time) {
Expand All @@ -169,13 +274,6 @@ private LocalDateTime parseDateTime(String time) {
throw exception;
}

public boolean isJobParameterized() {
ParametersDefinitionProperty paramDefinitions = target.getProperty(ParametersDefinitionProperty.class);
return paramDefinitions != null
&& paramDefinitions.getParameterDefinitions() != null
&& paramDefinitions.getParameterDefinitions().size() > 0;
}

@Restricted(NoExternalUse.class)
public String getDateTimeFormatting() {
return DATE_TIME_PATTERN;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public static ScheduleBuildGlobalConfiguration get() {

private String defaultStartTime;

private int gracePeriodMinutes = 2;

private transient LocalTime defaultScheduleLocalTime;

private static final Logger LOGGER = Logger.getLogger(ScheduleBuildGlobalConfiguration.class.getName());
Expand Down Expand Up @@ -78,6 +80,16 @@ public void load() {
}
}

public int getGracePeriodMinutes() {
return gracePeriodMinutes;
}

@DataBoundSetter
public void setGracePeriodMinutes(int gracePeriodMinutes) {
this.gracePeriodMinutes = gracePeriodMinutes;
save();
}

public String getDefaultScheduleTime() {
return getDefaultStartTime();
}
Expand Down
Loading
Loading