From 1433f837ba7e758a68695234e1dfd89ce9aaa7df Mon Sep 17 00:00:00 2001 From: Martin Polden Date: Mon, 25 Sep 2017 13:27:36 +0200 Subject: Read block-upgrade tag from deployment spec --- .../config/application/api/DeploymentSpec.java | 47 +++++-- .../yahoo/config/application/api/TimeWindow.java | 141 ++++++++++++++++++++ .../config/application/api/DeploymentSpecTest.java | 28 ++++ .../config/application/api/TimeWindowTest.java | 143 +++++++++++++++++++++ 4 files changed, 351 insertions(+), 8 deletions(-) create mode 100644 config-model-api/src/main/java/com/yahoo/config/application/api/TimeWindow.java create mode 100644 config-model-api/src/test/java/com/yahoo/config/application/api/TimeWindowTest.java (limited to 'config-model-api/src') diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java index 098f5620723..e75ba5871f7 100644 --- a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentSpec.java @@ -13,6 +13,7 @@ import java.io.FileReader; import java.io.IOException; import java.io.Reader; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -39,25 +40,29 @@ import java.util.stream.Collectors; public class DeploymentSpec { /** The empty deployment spec, specifying no zones or rotation, and defaults for all settings */ - public static final DeploymentSpec empty = new DeploymentSpec(Optional.empty(), - UpgradePolicy.defaultPolicy, - ImmutableList.of(), + public static final DeploymentSpec empty = new DeploymentSpec(Optional.empty(), + UpgradePolicy.defaultPolicy, + Collections.emptyList(), + Collections.emptyList(), ""); private final Optional globalServiceId; private final UpgradePolicy upgradePolicy; + private final List blockUpgrades; private final List steps; private final String xmlForm; - public DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy, List steps) { - this(globalServiceId, upgradePolicy, steps, null); + public DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy, + List blockUpgrades, List steps) { + this(globalServiceId, upgradePolicy, blockUpgrades, steps, null); } - private DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy, - List steps, String xmlForm) { + private DeploymentSpec(Optional globalServiceId, UpgradePolicy upgradePolicy, + List blockUpgrades, List steps, String xmlForm) { validateTotalDelay(steps); this.globalServiceId = globalServiceId; this.upgradePolicy = upgradePolicy; + this.blockUpgrades = blockUpgrades; this.steps = ImmutableList.copyOf(completeSteps(new ArrayList<>(steps))); this.xmlForm = xmlForm; validateZones(this.steps); @@ -137,6 +142,14 @@ public class DeploymentSpec { /** Returns the upgrade policy of this, which is defaultPolicy if none is specified */ public UpgradePolicy upgradePolicy() { return upgradePolicy; } + /** Returns whether upgrade can occur at the given instant */ + public boolean canUpgradeAt(Instant instant) { + return blockUpgrades.stream().noneMatch(timeWindow -> timeWindow.includes(instant)); + } + + /** Returns time windows where upgrade is disallowed */ + public List blockUpgrades() { return blockUpgrades; } + /** Returns the deployment steps of this in the order they will be performed */ public List steps() { return steps; } @@ -210,7 +223,8 @@ public class DeploymentSpec { else if (readGlobalServiceId(environmentTag).isPresent()) throw new IllegalArgumentException("Attribute 'global-service-id' is only valid on 'prod' tag."); } - return new DeploymentSpec(globalServiceId, readUpgradePolicy(root), steps, xmlForm); + return new DeploymentSpec(globalServiceId, readUpgradePolicy(root), readBlockUpgradeWindows(root), steps, + xmlForm); } /** Returns the given attribute as an integer, or 0 if it is not present */ @@ -244,6 +258,23 @@ public class DeploymentSpec { return Optional.of(globalServiceId); } } + + private static List readBlockUpgradeWindows(Element root) { + List timeWindows = new ArrayList<>(); + for (Element tag : XML.getChildren(root)) { + if (!"block-upgrade".equals(tag.getTagName())) { + continue; + } + String daySpec = tag.getAttribute("days"); + String hourSpec = tag.getAttribute("hours"); + String zoneSpec = tag.getAttribute("time-zone"); + if (zoneSpec.isEmpty()) { // Default to UTC time zone + zoneSpec = "UTC"; + } + timeWindows.add(TimeWindow.from(daySpec, hourSpec, zoneSpec)); + } + return Collections.unmodifiableList(timeWindows); + } private static UpgradePolicy readUpgradePolicy(Element root) { Element upgradeElement = XML.getChild(root, "upgrade"); diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/TimeWindow.java b/config-model-api/src/main/java/com/yahoo/config/application/api/TimeWindow.java new file mode 100644 index 00000000000..40b5a5370e7 --- /dev/null +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/TimeWindow.java @@ -0,0 +1,141 @@ +package com.yahoo.config.application.api; + +import java.time.DateTimeException; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoField; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.TreeSet; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +/** + * This class represents a window of time for selected hours on selected days. + * + * @author mpolden + */ +public class TimeWindow { + + private final List days; + private final List hours; + private final ZoneId zone; + + private TimeWindow(List days, List hours, ZoneId zone) { + this.days = Collections.unmodifiableList(new ArrayList<>(new TreeSet<>(days))); + this.hours = Collections.unmodifiableList(new ArrayList<>(new TreeSet<>(hours))); + this.zone = zone; + } + + /** Returns days in this time window */ + public List days() { + return days; + } + + /** Returns hours in this time window */ + public List hours() { + return hours; + } + + /** Returns the time zone of this time window */ + public ZoneId zone() { return zone; } + + /** Returns whether the given instant is in this time window */ + public boolean includes(Instant instant) { + LocalDateTime dt = LocalDateTime.ofInstant(instant, zone); + return days.contains(dt.getDayOfWeek()) && hours.contains(dt.getHour()); + } + + @Override + public String toString() { + return "time window for hour(s) " + + hours.toString() + + " on " + days.stream().map(DayOfWeek::name) + .map(String::toLowerCase) + .collect(Collectors.toList()).toString() + + " in " + zone; + } + + /** Parse a time window from the given day, hour and time zone specification */ + public static TimeWindow from(String daySpec, String hourSpec, String zoneSpec) { + List days = parse(daySpec, TimeWindow::parseDays); + List hours = parse(hourSpec, TimeWindow::parseHours); + ZoneId zone = zoneFrom(zoneSpec); + return new TimeWindow(days, hours, zone); + } + + /** Parse a specification, e.g. "1,4-5", using the given value parser */ + private static List parse(String spec, BiFunction> valueParser) { + List values = new ArrayList<>(); + String[] parts = spec.split(","); + for (String part : parts) { + if (part.contains("-")) { + String[] startAndEnd = part.split("-"); + if (startAndEnd.length != 2) { + throw new IllegalArgumentException("Invalid range '" + part + "'"); + } + values.addAll(valueParser.apply(startAndEnd[0], startAndEnd[1])); + } else { + values.addAll(valueParser.apply(part, part)); + } + } + return Collections.unmodifiableList(values); + } + + /** Returns a list of all hours occurring between startInclusive and endInclusive */ + private static List parseHours(String startInclusive, String endInclusive) { + int start = hourFrom(startInclusive); + int end = hourFrom(endInclusive); + if (end < start) { + throw new IllegalArgumentException(String.format("Invalid hour range '%s-%s'", startInclusive, + endInclusive)); + } + return IntStream.rangeClosed(start, end).boxed() + .collect(Collectors.toList()); + } + + /** Returns a list of all days occurring between startInclusive and endInclusive */ + private static List parseDays(String startInclusive, String endInclusive) { + DayOfWeek start = dayFrom(startInclusive); + DayOfWeek end = dayFrom(endInclusive); + if (end.getValue() < start.getValue()) { + throw new IllegalArgumentException(String.format("Invalid day range '%s-%s'", startInclusive, + endInclusive)); + } + return IntStream.rangeClosed(start.getValue(), end.getValue()).boxed() + .map(DayOfWeek::of) + .collect(Collectors.toList()); + } + + /** Parse day of week from string */ + private static DayOfWeek dayFrom(String day) { + return Arrays.stream(DayOfWeek.values()) + .filter(dayOfWeek -> day.length() >= 3 && dayOfWeek.name().toLowerCase().startsWith(day)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid day '" + day + "'")); + } + + /** Parse hour from string */ + private static int hourFrom(String hour) { + try { + return ChronoField.HOUR_OF_DAY.checkValidIntValue(Integer.parseInt(hour)); + } catch (DateTimeException|NumberFormatException e) { + throw new IllegalArgumentException("Invalid hour '" + hour + "'", e); + } + } + + /** Parse time zone from string */ + private static ZoneId zoneFrom(String zone) { + try { + return ZoneId.of(zone); + } catch (DateTimeException e) { + throw new IllegalArgumentException("Invalid time zone '" + zone + "'", e); + } + } + +} diff --git a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java index 95f9963d6f4..3540a5d785f 100644 --- a/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java +++ b/config-model-api/src/test/java/com/yahoo/config/application/api/DeploymentSpecTest.java @@ -6,6 +6,8 @@ import com.yahoo.config.provision.RegionName; import org.junit.Test; import java.io.StringReader; +import java.time.Instant; +import java.time.ZoneId; import java.util.Optional; import static org.junit.Assert.assertEquals; @@ -276,4 +278,30 @@ public class DeploymentSpecTest { } } + @Test + public void deploymentSpecWithBlockUpgrade() { + StringReader r = new StringReader( + "\n" + + " \n" + + " \n" + + " \n" + + " us-west-1\n" + + " \n" + + "" + ); + DeploymentSpec spec = DeploymentSpec.fromXml(r); + assertEquals(2, spec.blockUpgrades().size()); + assertEquals(ZoneId.of("UTC"), spec.blockUpgrades().get(0).zone()); + assertEquals(ZoneId.of("CET"), spec.blockUpgrades().get(1).zone()); + + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T14:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T15:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-18T16:15:30.00Z"))); + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-18T17:15:30.00Z"))); + + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); + assertFalse(spec.canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET + assertTrue(spec.canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + } + } diff --git a/config-model-api/src/test/java/com/yahoo/config/application/api/TimeWindowTest.java b/config-model-api/src/test/java/com/yahoo/config/application/api/TimeWindowTest.java new file mode 100644 index 00000000000..86ce0466213 --- /dev/null +++ b/config-model-api/src/test/java/com/yahoo/config/application/api/TimeWindowTest.java @@ -0,0 +1,143 @@ +package com.yahoo.config.application.api; + +import org.junit.Test; + +import java.time.Instant; + +import static java.time.DayOfWeek.FRIDAY; +import static java.time.DayOfWeek.MONDAY; +import static java.time.DayOfWeek.SATURDAY; +import static java.time.DayOfWeek.THURSDAY; +import static java.time.DayOfWeek.TUESDAY; +import static java.time.DayOfWeek.WEDNESDAY; +import static java.util.Arrays.asList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * @author mpolden + */ +public class TimeWindowTest { + + @Test + public void includesInstant() { + { + TimeWindow tw = TimeWindow.from("mon", "10,11", "UTC"); + Instant i0 = Instant.parse("2017-09-17T11:15:30.00Z"); // Wrong day + Instant i1 = Instant.parse("2017-09-18T09:15:30.00Z"); // Wrong hour + Instant i2 = Instant.parse("2017-09-18T10:15:30.00Z"); + Instant i3 = Instant.parse("2017-09-18T11:15:30.00Z"); + Instant i4 = Instant.parse("2017-09-18T12:15:30.00Z"); // Wrong hour + Instant i5 = Instant.parse("2017-09-19T11:15:30.00Z"); // Wrong day + + assertFalse("Instant " + i0 + " is not in window", tw.includes(i0)); + assertFalse("Instant " + i1 + " is not in window", tw.includes(i1)); + assertTrue("Instant " + i2 + " is in window", tw.includes(i2)); + assertTrue("Instant " + i3 + " is in window", tw.includes(i3)); + assertFalse("Instant " + i4 + " is not in window", tw.includes(i4)); + assertFalse("Instant " + i5 + " is not in window", tw.includes(i5)); + } + { + TimeWindow tw = TimeWindow.from("mon", "12,13", "CET"); + Instant i0 = Instant.parse("2017-09-17T11:15:30.00Z"); + Instant i1 = Instant.parse("2017-09-18T09:15:30.00Z"); + Instant i2 = Instant.parse("2017-09-18T10:15:30.00Z"); // Including offset this matches hour 12 + Instant i3 = Instant.parse("2017-09-18T11:15:30.00Z"); // Including offset this matches hour 13 + Instant i4 = Instant.parse("2017-09-18T12:15:30.00Z"); + Instant i5 = Instant.parse("2017-09-19T11:15:30.00Z"); + assertFalse("Instant " + i0 + " is not in window", tw.includes(i0)); + assertFalse("Instant " + i1 + " is not in window", tw.includes(i1)); + assertTrue("Instant " + i2 + " is in window", tw.includes(i2)); + assertTrue("Instant " + i3 + " is in window", tw.includes(i3)); + assertFalse("Instant " + i4 + " is not in window", tw.includes(i4)); + assertFalse("Instant " + i5 + " is not in window", tw.includes(i5)); + } + } + + @Test + public void validWindows() { + { + TimeWindow fz = TimeWindow.from("fri", "8,17-19", "UTC"); + assertEquals(asList(FRIDAY), fz.days()); + assertEquals(asList(8, 17, 18, 19), fz.hours()); + } + { + TimeWindow fz = TimeWindow.from("sat,", "8,17-19", "UTC"); + assertEquals(asList(SATURDAY), fz.days()); + assertEquals(asList(8, 17, 18, 19), fz.hours()); + } + { + TimeWindow fz = TimeWindow.from("tue,sat", "0,3,7,10", "UTC"); + assertEquals(asList(TUESDAY, SATURDAY), fz.days()); + assertEquals(asList(0, 3, 7, 10), fz.hours()); + } + { + TimeWindow fz = TimeWindow.from("mon,wed-thu", "0,17-19", "UTC"); + assertEquals(asList(MONDAY, WEDNESDAY, THURSDAY), fz.days()); + assertEquals(asList(0, 17, 18, 19), fz.hours()); + } + { + // Full day names is allowed + TimeWindow fz = TimeWindow.from("monday,wednesday-thursday", "0,17-19", "UTC"); + assertEquals(asList(MONDAY, WEDNESDAY, THURSDAY), fz.days()); + assertEquals(asList(0, 17, 18, 19), fz.hours()); + } + { + // Duplicate day and overlapping range is allowed + TimeWindow fz = TimeWindow.from("mon,wed-thu,mon", "3,1-4", "UTC"); + assertEquals(asList(MONDAY, WEDNESDAY, THURSDAY), fz.days()); + assertEquals(asList(1, 2, 3, 4), fz.hours()); + } + } + + @Test + public void invalidWindows() { + // Invalid time zone + assertInvalidZone("foo", "Invalid time zone 'foo'"); + + // Malformed day input + assertInvalidDays("", "Invalid day ''"); + assertInvalidDays("foo-", "Invalid range 'foo-'"); + assertInvalidDays("foo", "Invalid day 'foo'"); + assertInvalidDays("f", "Invalid day 'f'"); + // Window crossing week boundary is disallowed + assertInvalidDays("fri-tue", "Invalid day range 'fri-tue'"); + + // Malformed hour input + assertInvalidHours("", "Invalid hour ''"); + assertInvalidHours("24", "Invalid hour '24'"); + assertInvalidHours("-1-9", "Invalid range '-1-9'"); + // Window crossing day boundary is disallowed + assertInvalidHours("23-1", "Invalid hour range '23-1'"); + } + + private static void assertInvalidZone(String zoneSpec, String exceptionMessage) { + try { + TimeWindow.from("mon", "1", zoneSpec); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals(exceptionMessage, e.getMessage()); + } + } + + private static void assertInvalidDays(String daySpec, String exceptionMessage) { + try { + TimeWindow.from(daySpec, "1", "UTC"); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals(exceptionMessage, e.getMessage()); + } + } + + private static void assertInvalidHours(String hourSpec, String exceptionMessage) { + try { + TimeWindow.from("mon", hourSpec, "UTC"); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals(exceptionMessage, e.getMessage()); + } + } + +} -- cgit v1.2.3