/**
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.aurora.scheduler.cron;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Splitter;
import com.google.common.collect.BiMap;
import com.google.common.collect.ContiguousSet;
import com.google.common.collect.DiscreteDomain;
import com.google.common.collect.ImmutableBiMap;
import com.google.common.collect.ImmutableRangeSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Range;
import com.google.common.collect.RangeSet;
import com.google.common.collect.TreeRangeSet;
import static java.util.Objects.requireNonNull;
import static com.google.common.base.Preconditions.checkArgument;
/**
* A pattern that describes one or more cron 5-tuples (minute, hour, dayOfMonth, month, dayOfWeek).
*
* CrontabEntries are immutable and thread-safe. Unless otherwise specified any public methods will
* throw {@link java.lang.NullPointerException} if given a {@code null} parameter.
*
* The quickest way to create a {@code CrontabEntry} is to use {@link #parse(String)} or
* {@link #tryParse(String)}.
*/
public final class CrontabEntry {
private static final Range<Integer> MINUTE =
Range.closed(0, 59).canonical(DiscreteDomain.integers());
private static final Range<Integer> HOUR =
Range.closed(0, 23).canonical(DiscreteDomain.integers());
private static final Range<Integer> DAY_OF_MONTH =
Range.closed(1, 31).canonical(DiscreteDomain.integers());
private static final Range<Integer> MONTH =
Range.closed(1, 12).canonical(DiscreteDomain.integers());
// NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday.
private static final Range<Integer> DAY_OF_WEEK =
Range.closed(0, 6).canonical(DiscreteDomain.integers());
private final RangeSet<Integer> minute;
private final RangeSet<Integer> hour;
private final RangeSet<Integer> dayOfMonth;
private final RangeSet<Integer> month;
private final RangeSet<Integer> dayOfWeek;
private CrontabEntry(
RangeSet<Integer> minute,
RangeSet<Integer> hour,
RangeSet<Integer> dayOfMonth,
RangeSet<Integer> month,
RangeSet<Integer> dayOfWeek) {
checkEnclosed("minute", MINUTE, minute);
checkEnclosed("hour", HOUR, hour);
checkEnclosed("dayOfMonth", DAY_OF_MONTH, dayOfMonth);
checkEnclosed("month", MONTH, month);
checkEnclosed("dayOfWeek", DAY_OF_WEEK, dayOfWeek);
this.minute = ImmutableRangeSet.copyOf(minute);
this.hour = ImmutableRangeSet.copyOf(hour);
this.dayOfMonth = ImmutableRangeSet.copyOf(dayOfMonth);
this.month = ImmutableRangeSet.copyOf(month);
this.dayOfWeek = ImmutableRangeSet.copyOf(dayOfWeek);
checkArgument(hasWildcardDayOfMonth() || hasWildcardDayOfWeek(),
"Specifying both dayOfWeek and dayOfMonth is not supported.");
}
private static void checkEnclosed(
String fieldName,
Range<Integer> fieldEnclosure,
RangeSet<Integer> field) {
checkArgument(fieldEnclosure.encloses(field.span()),
String.format(
"Bad specification for field %s: span(%s) = %s is not enclosed by boundary %s.",
fieldName,
field,
field.span(),
fieldEnclosure));
}
/**
* Create a new {@link CrontabEntry} from a crontab(5)-style schedule.
*
* The acceptable format of {@code schedule} is mostly compatible with FreeBSD's crontab(5)
* format, excluding "extensions" like "@every_minute."
*
* A crontab(5) entry consists of 5 fields (minute, hour, dayOfMonth, month, dayOfWeek) and for
* each field supports singletons ("50"), wildcards ("*"), ranges ("1-50", "MON-SAT"), and
* "skips" ("1-50/2", "*/2").
*
* See http://www.freebsd.org/cgi/man.cgi?query=crontab&sektion=5 for full syntax examples.
*
* NOTE: While entries such as "Thursdays that fall on the 15th day of the month" are expressible
* in the original BSD syntax, this parser rejects them with {@link IllegalArgumentException}.
*
* @param schedule The crontab entry to parse.
* @return A new entry if parsing was successful.
* @throws IllegalArgumentException if parsing failed for any reason.
*/
public static CrontabEntry parse(String schedule) throws IllegalArgumentException {
return new Parser(schedule).get();
}
/**
* Create a new {@link CrontabEntry} from a crontab(5)-style schedule.
*
* @see #parse(String)
* @param schedule The crontab entry to parse.
* @return A new entry if parsing was successful, absent otherwise.
*/
public static Optional<CrontabEntry> tryParse(String schedule) {
try {
return Optional.of(parse(schedule));
} catch (IllegalArgumentException e) {
return Optional.absent();
}
}
private static CrontabEntry from(
RangeSet<Integer> minute,
RangeSet<Integer> hour,
RangeSet<Integer> dayOfMonth,
RangeSet<Integer> month,
RangeSet<Integer> dayOfWeek) throws IllegalArgumentException {
return new CrontabEntry(minute, hour, dayOfMonth, month, dayOfWeek);
}
private RangeSet<Integer> getMinute() {
return minute;
}
private RangeSet<Integer> getHour() {
return hour;
}
private RangeSet<Integer> getDayOfMonth() {
return dayOfMonth;
}
private RangeSet<Integer> getMonth() {
return month;
}
/**
* The days of the week this entry matches. 0 is Sun and 6 is Sat.
*
* @return An immutable view of the days of the week this entry matches within [0,7).
*/
public RangeSet<Integer> getDayOfWeek() {
return dayOfWeek;
}
@VisibleForTesting
boolean hasWildcardMinute() {
return getMinute().encloses(MINUTE);
}
@VisibleForTesting
boolean hasWildcardHour() {
return getHour().encloses(HOUR);
}
/**
* True if this entry covers all possible days of the month.
*/
public boolean hasWildcardDayOfMonth() {
return getDayOfMonth().encloses(DAY_OF_MONTH);
}
@VisibleForTesting
boolean hasWildcardMonth() {
return getMonth().encloses(MONTH);
}
/**
* True if this entry covers all possible days of the week.
*/
public boolean hasWildcardDayOfWeek() {
return getDayOfWeek().encloses(DAY_OF_WEEK);
}
private String fieldToString(RangeSet<Integer> rangeSet, Range<Integer> coveringRange) {
if (rangeSet.asRanges().size() == 1 && rangeSet.encloses(coveringRange)) {
return "*";
}
List<String> components = Lists.newArrayList();
for (Range<Integer> range : rangeSet.asRanges()) {
ContiguousSet<Integer> set = ContiguousSet.create(range, DiscreteDomain.integers());
if (set.size() == 1) {
components.add(set.first().toString());
} else {
components.add(set.first() + "-" + set.last());
}
}
return String.join(",", components);
}
/**
* The minute component, in canonical form.
*/
public String getMinuteAsString() {
return fieldToString(getMinute(), MINUTE);
}
/**
* The hour component, in canonical form.
*/
public String getHourAsString() {
return fieldToString(getHour(), HOUR);
}
/**
* The dayOfMonth component, in canonical form.
*/
public String getDayOfMonthAsString() {
return fieldToString(getDayOfMonth(), DAY_OF_MONTH);
}
/**
* The month component, in canonical form.
*/
public String getMonthAsString() {
return fieldToString(getMonth(), MONTH);
}
/**
* The dayOfWeek component, in canonical form.
*/
public String getDayOfWeekAsString() {
return fieldToString(getDayOfWeek(), DAY_OF_WEEK);
}
/**
* Returns a parsable string representation schedule such that
* {@code c.equals(CrontabEntry.parse(c.toString())}.
*/
@Override
public String toString() {
return String.join(
" ",
getMinuteAsString(),
getHourAsString(),
getDayOfMonthAsString(),
getMonthAsString(),
getDayOfWeekAsString());
}
/**
* True when both sides would match the same set of instants.
*/
@Override
public boolean equals(Object o) {
if (!(o instanceof CrontabEntry)) {
return false;
}
CrontabEntry that = (CrontabEntry) o;
return Objects.equals(getMinute(), that.getMinute())
&& Objects.equals(getHour(), that.getHour())
&& Objects.equals(getDayOfMonth(), that.getDayOfMonth())
&& Objects.equals(getMonth(), that.getMonth())
&& Objects.equals(getDayOfWeek(), that.getDayOfWeek());
}
@Override
public int hashCode() {
return Objects.hash(getMinute(), getHour(), getDayOfWeek(), getMonth(), getDayOfMonth());
}
private static class Parser {
private static final Pattern CRONTAB_ENTRY = Pattern.compile(
"^(?<minute>\\S+)"
+ "\\s+(?<hour>\\S+)"
+ "\\s+(?<dayOfMonth>\\S+)"
+ "\\s+(?<month>\\S+)"
+ "\\s+(?<dayOfWeek>\\S+)$"
);
// A single time like "5", "10", "50".
private static final Pattern NUMBER = Pattern.compile("^(?<number>\\d+)$");
// A wildcard ("*").
private static final Pattern WILDCARD = Pattern.compile("^\\*$");
// A range like "1-2", "5-10", "14-50".
private static final Pattern RANGE = Pattern.compile("^(?<lower>\\d+)-(?<upper>\\d+)$");
// A wildcard with a "skip" like "*/5", "*/10"
private static final Pattern WILDCARD_WITH_SKIP = Pattern.compile("^\\*/(?<skip>\\d+)$");
// A range with a "skip" like "1-2/2", "0-59/5"
private static final Pattern RANGE_WITH_SKIP =
Pattern.compile("^(?<lower>\\d+)-(?<upper>\\d+)/(?<skip>\\d+)$");
private static final BiMap<String, Integer> MONTH_NAMES = ImmutableBiMap
.<String, Integer>builder()
.put("JAN", 1)
.put("FEB", 2)
.put("MAR", 3)
.put("APR", 4)
.put("MAY", 5)
.put("JUN", 6)
.put("JUL", 7)
.put("AUG", 8)
.put("SEP", 9)
.put("OCT", 10)
.put("NOV", 11)
.put("DEC", 12)
.build();
// NOTE: Unlike FreeBSD we don't allow "7" to mean Sunday.
private static final BiMap<String, Integer> DAY_NAMES = ImmutableBiMap
.<String, Integer>builder()
.put("SUN", 0)
.put("MON", 1)
.put("TUE", 2)
.put("WED", 3)
.put("THU", 4)
.put("FRI", 5)
.put("SAT", 6)
.build();
private final String rawMinute;
private final String rawHour;
private final String rawDayOfMonth;
private final String rawMonth;
private final String rawDayOfWeek;
Parser(String schedule) throws IllegalArgumentException {
Matcher matcher = CRONTAB_ENTRY.matcher(schedule);
checkArgument(matcher.matches(), "Invalid cron schedule %s", schedule);
rawMinute = requireNonNull(matcher.group("minute"));
rawHour = requireNonNull(matcher.group("hour"));
rawDayOfMonth = requireNonNull(matcher.group("dayOfMonth"));
rawMonth = requireNonNull(matcher.group("month"));
rawDayOfWeek = requireNonNull(matcher.group("dayOfWeek"));
}
CrontabEntry get() throws IllegalArgumentException {
return CrontabEntry.from(
parseMinute(),
parseHour(),
parseDayOfMonth(),
parseMonth(),
parseDayOfWeek());
}
private List<String> getComponents(String rawField) {
return Splitter.on(",").omitEmptyStrings().splitToList(rawField);
}
private String replaceNameAliases(String rawComponent, Map<String, Integer> names) {
String component = rawComponent.toUpperCase(Locale.ENGLISH);
for (Map.Entry<String, Integer> entry : names.entrySet()) {
if (component.contains(entry.getKey())) {
component = component.replaceAll(entry.getKey(), entry.getValue().toString());
}
}
return component;
}
private static RangeSet<Integer> parseComponent(
final Range<Integer> enclosure,
String rawComponent) throws IllegalArgumentException {
if (WILDCARD.matcher(rawComponent).matches()) {
return ImmutableRangeSet.of(enclosure);
}
Matcher matcher = NUMBER.matcher(rawComponent);
if (matcher.matches()) {
int number = Integer.parseInt(matcher.group("number"));
Range<Integer> range = Range.singleton(number).canonical(DiscreteDomain.integers());
checkArgument(enclosure.encloses(range), "%s does not enclose %s", enclosure, range);
return ImmutableRangeSet.of(range);
}
matcher = RANGE.matcher(rawComponent);
if (matcher.matches()) {
int lower = Integer.parseInt(matcher.group("lower"));
int upper = Integer.parseInt(matcher.group("upper"));
Range<Integer> range = Range.closed(lower, upper).canonical(DiscreteDomain.integers());
checkArgument(enclosure.encloses(range), "%s does not enclose %s", enclosure, range);
return ImmutableRangeSet.of(range);
}
matcher = WILDCARD_WITH_SKIP.matcher(rawComponent);
if (matcher.matches()) {
int skip = Integer.parseInt(matcher.group("skip"));
int start = enclosure.lowerEndpoint();
checkArgument(skip > 0);
ImmutableRangeSet.Builder<Integer> rangeSet = ImmutableRangeSet.builder();
for (int i = start; enclosure.contains(i); i += skip) {
rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers()));
}
return rangeSet.build();
}
matcher = RANGE_WITH_SKIP.matcher(rawComponent);
if (matcher.matches()) {
final int lower = Integer.parseInt(matcher.group("lower"));
final int upper = Integer.parseInt(matcher.group("upper"));
final int skip = Integer.parseInt(matcher.group("skip"));
Range<Integer> range = Range.closed(lower, upper).canonical(DiscreteDomain.integers());
checkArgument(enclosure.encloses(range), "%s does not enclose %s", enclosure, range);
checkArgument(skip > 0, "skip value %s must be >0", skip);
checkArgument(skip < upper, "skip value %s must be smaller than %s", skip, upper);
ImmutableRangeSet.Builder<Integer> rangeSet = ImmutableRangeSet.builder();
for (int i = lower; range.contains(i); i += skip) {
rangeSet.add(Range.singleton(i).canonical(DiscreteDomain.integers()));
}
return rangeSet.build();
}
throw new IllegalArgumentException(
"Cron schedule component " + rawComponent + " does not match any known patterns.");
}
private RangeSet<Integer> parseMinute() {
RangeSet<Integer> minutes = TreeRangeSet.create();
for (String component : getComponents(rawMinute)) {
minutes.addAll(parseComponent(MINUTE, component));
}
return ImmutableRangeSet.copyOf(minutes);
}
private RangeSet<Integer> parseHour() {
RangeSet<Integer> hours = TreeRangeSet.create();
for (String component : getComponents(rawHour)) {
hours.addAll(parseComponent(HOUR, component));
}
return ImmutableRangeSet.copyOf(hours);
}
private RangeSet<Integer> parseDayOfWeek() {
RangeSet<Integer> daysOfWeek = TreeRangeSet.create();
for (String component : getComponents(rawDayOfWeek)) {
daysOfWeek.addAll(parseComponent(DAY_OF_WEEK, replaceNameAliases(component, DAY_NAMES)));
}
return ImmutableRangeSet.copyOf(daysOfWeek);
}
private RangeSet<Integer> parseMonth() {
RangeSet<Integer> months = TreeRangeSet.create();
for (String component : getComponents(rawMonth)) {
months.addAll(parseComponent(MONTH, replaceNameAliases(component, MONTH_NAMES)));
}
return ImmutableRangeSet.copyOf(months);
}
private RangeSet<Integer> parseDayOfMonth() {
RangeSet<Integer> daysOfMonth = TreeRangeSet.create();
for (String component : getComponents(rawDayOfMonth)) {
daysOfMonth.addAll(parseComponent(DAY_OF_MONTH, component));
}
return ImmutableRangeSet.copyOf(daysOfMonth);
}
}
}