diff options
author | Martin Polden <mpolden@mpolden.no> | 2022-01-07 10:56:41 +0100 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2022-01-07 14:43:37 +0100 |
commit | ae843dd9bd28be5c8445adf10dc7c0a6a69f2d97 (patch) | |
tree | 7d7c5b2fc602c54e3d634d0a0c07ab13d0ab1981 | |
parent | e11472ccb26601fe74004a2400b44ea000c88d6f (diff) |
Support date range in block window
7 files changed, 311 insertions, 80 deletions
diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java index 23a5dce3c25..67ddb9ef83c 100644 --- a/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/DeploymentInstanceSpec.java @@ -6,13 +6,16 @@ import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.RegionName; +import java.time.Duration; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.HashSet; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * The deployment spec for an application instance @@ -21,6 +24,9 @@ import java.util.stream.Collectors; */ public class DeploymentInstanceSpec extends DeploymentSpec.Steps { + /** The maximum number of consecutive days Vespa upgrades are allowed to be blocked */ + private static final int maxUpgradeBlockingDays = 21; + /** The name of the instance this step deploys */ private final InstanceName name; @@ -40,7 +46,8 @@ public class DeploymentInstanceSpec extends DeploymentSpec.Steps { Optional<String> globalServiceId, Optional<AthenzService> athenzService, Notifications notifications, - List<Endpoint> endpoints) { + List<Endpoint> endpoints, + Instant now) { super(steps); this.name = name; this.upgradePolicy = upgradePolicy; @@ -52,6 +59,7 @@ public class DeploymentInstanceSpec extends DeploymentSpec.Steps { this.endpoints = List.copyOf(endpoints); validateZones(new HashSet<>(), new HashSet<>(), this); validateEndpoints(steps(), globalServiceId, this.endpoints); + validateChangeBlockers(changeBlockers, now); } public InstanceName name() { return name; } @@ -109,6 +117,36 @@ public class DeploymentInstanceSpec extends DeploymentSpec.Steps { } } + private void validateChangeBlockers(List<DeploymentSpec.ChangeBlocker> changeBlockers, Instant now) { + // Find all possible dates an upgrade block window can start + Stream<Instant> blockingFrom = changeBlockers.stream() + .filter(blocker -> blocker.blocksVersions()) + .map(blocker -> blocker.window()) + .map(window -> window.dateRange().start() + .map(date -> date.atStartOfDay(window.zone()) + .toInstant()) + .orElse(now)) + .distinct(); + if (!blockingFrom.allMatch(this::canUpgradeWithinDeadline)) { + throw new IllegalArgumentException("Cannot block Vespa upgrades for longer than " + + maxUpgradeBlockingDays + " consecutive days"); + } + } + + /** Returns whether this allows upgrade within deadline, relative to given instant */ + private boolean canUpgradeWithinDeadline(Instant instant) { + instant = instant.truncatedTo(ChronoUnit.HOURS); + Duration step = Duration.ofHours(1); + Duration max = Duration.ofDays(maxUpgradeBlockingDays); + for (Instant current = instant; !canUpgradeAt(current); current = current.plus(step)) { + Duration blocked = Duration.between(instant, current); + if (blocked.compareTo(max) > 0) { + return false; + } + } + return true; + } + /** Returns the upgrade policy of this, which is defaultPolicy if none is specified */ public DeploymentSpec.UpgradePolicy upgradePolicy() { return upgradePolicy; } 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 index 5a2b3a10fe1..14deb5d5720 100644 --- 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 @@ -4,20 +4,23 @@ package com.yahoo.config.application.api; import java.time.DateTimeException; import java.time.DayOfWeek; import java.time.Instant; +import java.time.LocalDate; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.format.DateTimeParseException; import java.time.temporal.ChronoField; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; +import java.util.Optional; 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. + * This class represents a window of time for selected hours, days and dates. * * @author mpolden */ @@ -26,11 +29,20 @@ public class TimeWindow { private final List<DayOfWeek> days; private final List<Integer> hours; private final ZoneId zone; + private final LocalDateRange dateRange; - private TimeWindow(List<DayOfWeek> days, List<Integer> hours, ZoneId zone) { + private TimeWindow(List<DayOfWeek> days, List<Integer> hours, ZoneId zone, LocalDateRange dateRange) { this.days = Objects.requireNonNull(days).stream().distinct().sorted().collect(Collectors.toUnmodifiableList()); this.hours = Objects.requireNonNull(hours).stream().distinct().sorted().collect(Collectors.toUnmodifiableList()); this.zone = Objects.requireNonNull(zone); + this.dateRange = Objects.requireNonNull(dateRange); + if (days.isEmpty()) throw new IllegalArgumentException("At least one day must be specified"); + if (hours.isEmpty()) throw new IllegalArgumentException("At least one hour must be specified"); + for (var day : days) { + if (!dateRange.includes(day)) { + throw new IllegalArgumentException("Invalid day: " + dateRange + " does not contain " + day); + } + } } /** Returns days in this time window */ @@ -46,10 +58,17 @@ public class TimeWindow { /** Returns the time zone of this time window */ public ZoneId zone() { return zone; } + /** Returns the date range of this time window applies to */ + public LocalDateRange dateRange() { + return dateRange; + } + /** 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()); + return days.contains(dt.getDayOfWeek()) && + hours.contains(dt.getHour()) && + dateRange.includes(dt.toLocalDate()); } @Override @@ -59,15 +78,19 @@ public class TimeWindow { " on " + days.stream().map(DayOfWeek::name) .map(String::toLowerCase) .collect(Collectors.toList()) + - " in " + zone; + " in time zone " + zone + " and " + dateRange.toString(); } /** Parse a time window from the given day, hour and time zone specification */ - public static TimeWindow from(String daySpec, String hourSpec, String zoneSpec) { - List<DayOfWeek> days = parse(daySpec, TimeWindow::parseDays); - List<Integer> hours = parse(hourSpec, TimeWindow::parseHours); - ZoneId zone = zoneFrom(zoneSpec); - return new TimeWindow(days, hours, zone); + public static TimeWindow from(String daySpec, String hourSpec, String zoneSpec, String dateStart, String dateEnd) { + List<DayOfWeek> days = daySpec.isEmpty() + ? List.of(DayOfWeek.values()) // All days by default + : parse(daySpec, TimeWindow::parseDays); + List<Integer> hours = hourSpec.isEmpty() + ? IntStream.rangeClosed(0, 23).boxed().collect(Collectors.toList()) // All hours by default + : parse(hourSpec, TimeWindow::parseHours); + ZoneId zone = zoneFrom(zoneSpec.isEmpty() ? "UTC" : zoneSpec); + return new TimeWindow(days, hours, zone, LocalDateRange.from(dateStart, dateEnd)); } /** Parse a specification, e.g. "1,4-5", using the given value parser */ @@ -97,7 +120,7 @@ public class TimeWindow { endInclusive)); } return IntStream.rangeClosed(start, end).boxed() - .collect(Collectors.toList()); + .collect(Collectors.toList()); } /** Returns a list of all days occurring between startInclusive and endInclusive */ @@ -109,16 +132,16 @@ public class TimeWindow { endInclusive)); } return IntStream.rangeClosed(start.getValue(), end.getValue()).boxed() - .map(DayOfWeek::of) - .collect(Collectors.toList()); + .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 + "'")); + .filter(dayOfWeek -> day.length() >= 3 && dayOfWeek.name().toLowerCase().startsWith(day)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid day '" + day + "'")); } /** Parse hour from string */ @@ -139,4 +162,67 @@ public class TimeWindow { } } + /** A range of local dates, which may be unbounded */ + public static class LocalDateRange { + + private final Optional<LocalDate> start; + private final Optional<LocalDate> end; + + private LocalDateRange(Optional<LocalDate> start, Optional<LocalDate> end) { + this.start = Objects.requireNonNull(start); + this.end = Objects.requireNonNull(end); + if (start.isPresent() && end.isPresent() && start.get().isAfter(end.get())) { + throw new IllegalArgumentException("Invalid date range: start date " + start.get() + + " is after end date " + end.get()); + } + } + + /** Returns the starting date of this (inclusive), if any */ + public Optional<LocalDate> start() { + return start; + } + + /** Returns the ending date of this (inclusive), if any */ + public Optional<LocalDate> end() { + return end; + } + + /** Returns whether this contains the given day */ + private boolean includes(DayOfWeek day) { + if (start.isEmpty() || end.isEmpty()) return true; + for (LocalDate date = start.get(); !date.isAfter(end.get()); date = date.plusDays(1)) { + if (date.getDayOfWeek() == day) { + return true; + } + } + return false; + } + + /** Returns whether includes the given date */ + private boolean includes(LocalDate date) { + if (start.isPresent() && date.isBefore(start.get())) return false; + if (end.isPresent() && date.isAfter(end.get())) return false; + return true; + } + + @Override + public String toString() { + return "date range [" + start.map(LocalDate::toString).orElse("any date") + + ", " + end.map(LocalDate::toString).orElse("any date") + "]"; + } + + private static LocalDateRange from(String start, String end) { + try { + return new LocalDateRange(optionalDate(start), optionalDate(end)); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Could not parse date range '" + start + "' and '" + end + "'", e); + } + } + + private static Optional<LocalDate> optionalDate(String date) { + return Optional.of(date).filter(s -> !s.isEmpty()).map(LocalDate::parse); + } + + } + } diff --git a/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java b/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java index 493a72b2018..8f866654d56 100644 --- a/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java +++ b/config-model-api/src/main/java/com/yahoo/config/application/api/xml/DeploymentSpecXmlReader.java @@ -27,7 +27,9 @@ import org.w3c.dom.Node; import java.io.IOException; import java.io.Reader; +import java.time.Clock; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -67,22 +69,28 @@ public class DeploymentSpecXmlReader { private static final String testerFlavorAttribute = "tester-flavor"; private final boolean validate; + private final Clock clock; private final List<DeprecatedElement> deprecatedElements = new ArrayList<>(); - /** Creates a validating reader */ + /** + * Create a deployment spec reader + * @param validate true to validate the input, false to accept any input which can be unambiguously parsed + * @param clock clock to use when validating time constraints + */ + public DeploymentSpecXmlReader(boolean validate, Clock clock) { + this.validate = validate; + this.clock = clock; + } + public DeploymentSpecXmlReader() { this(true); } - /** - * Creates a deployment spec reader - * - * @param validate true to validate the input, false to accept any input which can be unambiguously parsed - */ public DeploymentSpecXmlReader(boolean validate) { - this.validate = validate; + this(validate, Clock.systemUTC()); } + /** Reads a deployment spec from given reader */ public DeploymentSpec read(Reader reader) { try { return read(IOUtils.readAll(reader)); @@ -169,6 +177,7 @@ public class DeploymentSpecXmlReader { List<Endpoint> endpoints = readEndpoints(instanceTag, Optional.of(instanceNameString), steps); // Build and return instances with these values + Instant now = clock.instant(); return Arrays.stream(instanceNameString.split(",")) .map(name -> name.trim()) .map(name -> new DeploymentInstanceSpec(InstanceName.from(name), @@ -179,7 +188,8 @@ public class DeploymentSpecXmlReader { globalServiceId.asOptional(), athenzService, notifications, - endpoints)) + endpoints, + now)) .collect(Collectors.toList()); } @@ -430,9 +440,11 @@ public class DeploymentSpecXmlReader { String daySpec = tag.getAttribute("days"); String hourSpec = tag.getAttribute("hours"); String zoneSpec = tag.getAttribute("time-zone"); - if (zoneSpec.isEmpty()) zoneSpec = "UTC"; // default + String dateStart = tag.getAttribute("from-date"); + String dateEnd = tag.getAttribute("to-date"); + return new DeploymentSpec.ChangeBlocker(blockRevisions, blockVersions, - TimeWindow.from(daySpec, hourSpec, zoneSpec)); + TimeWindow.from(daySpec, hourSpec, zoneSpec, dateStart, dateEnd)); } /** Returns true if the given value is "true", or if it is missing */ 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 43ccc34284f..2fa2ba83291 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 @@ -2,12 +2,15 @@ package com.yahoo.config.application.api; import com.google.common.collect.ImmutableSet; +import com.yahoo.config.application.api.xml.DeploymentSpecXmlReader; import com.yahoo.config.provision.Environment; import com.yahoo.config.provision.InstanceName; import com.yahoo.config.provision.RegionName; +import com.yahoo.test.ManualClock; import org.junit.Test; import java.io.StringReader; +import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; @@ -771,6 +774,7 @@ public class DeploymentSpecTest { " <instance id='default'>" + " <block-change revision='false' days='mon,tue' hours='15-16'/>" + " <block-change days='sat' hours='10' time-zone='CET'/>" + + " <block-change days='mon-sun' hours='0-23' time-zone='CET' from-date='2022-01-01' to-date='2022-01-15'/>" + " <prod>" + " <region active='true'>us-west-1</region>" + " </prod>" + @@ -778,7 +782,7 @@ public class DeploymentSpecTest { "</deployment>" ); DeploymentSpec spec = DeploymentSpec.fromXml(r); - assertEquals(2, spec.requireInstance("default").changeBlocker().size()); + assertEquals(3, spec.requireInstance("default").changeBlocker().size()); assertTrue(spec.requireInstance("default").changeBlocker().get(0).blocksVersions()); assertFalse(spec.requireInstance("default").changeBlocker().get(0).blocksRevisions()); assertEquals(ZoneId.of("UTC"), spec.requireInstance("default").changeBlocker().get(0).window().zone()); @@ -795,6 +799,8 @@ public class DeploymentSpecTest { assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T09:15:30.00Z"))); assertFalse(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T08:15:30.00Z"))); // 10 in CET assertTrue(spec.requireInstance("default").canUpgradeAt(Instant.parse("2017-09-23T10:15:30.00Z"))); + + assertFalse(spec.requireInstance("default").canUpgradeAt(Instant.parse("2022-01-15T16:00:00.00Z"))); } @Test @@ -812,11 +818,13 @@ public class DeploymentSpecTest { DeploymentSpec spec = DeploymentSpec.fromXml(r); - String inheritedChangeBlocker = "change blocker revision=false version=true window=time window for hour(s) [15, 16] on [monday, tuesday] in UTC"; + String inheritedChangeBlocker = "change blocker revision=false version=true window=time window for hour(s) " + + "[15, 16] on [monday, tuesday] in time zone UTC and date range [any date, any date]"; assertEquals(2, spec.requireInstance("instance1").changeBlocker().size()); assertEquals(inheritedChangeBlocker, spec.requireInstance("instance1").changeBlocker().get(0).toString()); - assertEquals("change blocker revision=true version=true window=time window for hour(s) [10] on [saturday] in CET", + assertEquals("change blocker revision=true version=true window=time window for hour(s) [10] on " + + "[saturday] in time zone CET and date range [any date, any date]", spec.requireInstance("instance1").changeBlocker().get(1).toString()); assertEquals(1, spec.requireInstance("instance2").changeBlocker().size()); @@ -1269,10 +1277,45 @@ public class DeploymentSpecTest { spec.requireInstance("main").endpoints()); } + @Test + public void disallowExcessiveUpgradeBlocking() { + List<String> specs = List.of( + "<deployment>\n" + + " <block-change/>\n" + + "</deployment>", + + "<deployment>\n" + + " <block-change days=\"mon-wed\"/>\n" + + " <block-change days=\"tue-sun\"/>\n" + + "</deployment>", + + "<deployment>\n" + + " <block-change to-date=\"2023-01-01\"/>\n" + + "</deployment>", + + // Convoluted example of blocking too long + "<deployment>\n" + + " <block-change days=\"sat-sun\"/>\n" + + " <block-change days=\"mon-fri\" hours=\"0-10\" from-date=\"2023-01-01\" to-date=\"2023-01-15\"/>\n" + + " <block-change days=\"mon-fri\" hours=\"11-23\" from-date=\"2023-01-01\" to-date=\"2023-01-15\"/>\n" + + " <block-change from-date=\"2023-01-14\" to-date=\"2023-01-31\"/>" + + "</deployment>" + ); + ManualClock clock = new ManualClock(); + clock.setInstant(Instant.parse("2022-01-05T15:00:00.00Z")); + for (var spec : specs) { + assertInvalid(spec, "Cannot block Vespa upgrades for longer than 21 consecutive days", clock); + } + } + private static void assertInvalid(String deploymentSpec, String errorMessagePart) { + assertInvalid(deploymentSpec, errorMessagePart, new ManualClock()); + } + + private static void assertInvalid(String deploymentSpec, String errorMessagePart, Clock clock) { if (errorMessagePart.isEmpty()) throw new IllegalArgumentException("Message part must be non-empty"); try { - DeploymentSpec.fromXml(deploymentSpec); + new DeploymentSpecXmlReader(true, clock).read(deploymentSpec); fail("Expected exception"); } catch (IllegalArgumentException e) { assertTrue("\"" + e.getMessage() + "\" contains \"" + errorMessagePart + "\"", 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 index 98a53dfd3df..a76f156ebb1 100644 --- 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 @@ -3,7 +3,11 @@ package com.yahoo.config.application.api; import org.junit.Test; +import java.time.DayOfWeek; import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; import static java.time.DayOfWeek.FRIDAY; import static java.time.DayOfWeek.MONDAY; @@ -11,7 +15,6 @@ 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; @@ -25,71 +28,106 @@ public class TimeWindowTest { @Test public void includesInstant() { { - TimeWindow tw = TimeWindow.from("mon", "10,11", "UTC"); + 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)); + assertOutside(tw, i0); + assertOutside(tw, i1); + assertInside(tw, i2); + assertInside(tw, i3); + assertOutside(tw, i4); + assertOutside(tw, i5); } { - TimeWindow tw = TimeWindow.from("mon", "12,13", "CET"); + 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)); + assertOutside(tw, i0); + assertOutside(tw, i1); + assertInside(tw, i2); + assertInside(tw, i3); + assertOutside(tw, i4); + assertOutside(tw, i5); + } + { + TimeWindow tw = TimeWindow.from("mon-sun", "0-23", "CET", "2022-01-15", "2022-02-15"); + Instant i0 = Instant.parse("2022-01-14T12:00:00.00Z"); // Before window + Instant i1 = Instant.parse("2022-01-14T23:00:00.00Z"); // Inside window because of time zone offset + Instant i2 = Instant.parse("2022-02-05T12:00:00.00Z"); + Instant i3 = Instant.parse("2022-02-14T23:00:00.00Z"); + Instant i4 = Instant.parse("2022-02-15T23:00:00.00Z"); // After window because of time zone offset + Instant i5 = Instant.parse("2022-02-16T12:00:00.00Z"); // After window + assertOutside(tw, i0); + assertInside(tw, i1); + assertInside(tw, i2); + assertInside(tw, i3); + assertOutside(tw, i4); + assertOutside(tw, i5); + + TimeWindow tw2 = TimeWindow.from("sun", "1", "CET", "2022-01-01", "2022-01-02"); + Instant i6 = Instant.parse("2022-01-01T00:00:00.00Z"); // Wrong day + Instant i7 = Instant.parse("2022-01-02T01:00:00.00Z"); // Wrong hour because of time zone offset + Instant i8 = Instant.parse("2022-01-02T00:00:00.00Z"); + assertOutside(tw2, i6); + assertOutside(tw2, i7); + assertInside(tw2, i8); + + TimeWindow tw3 = TimeWindow.from("", "", "CET", "2022-01-02", ""); + Instant i9 = Instant.parse("2022-02-15T00:00:00.00Z"); + assertOutside(tw3, i6); + assertInside(tw3, i7); + assertInside(tw3, i8); + assertInside(tw3, i9); } } @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 tw = TimeWindow.from("fri", "8,17-19", "UTC", "", ""); + assertEquals(List.of(FRIDAY), tw.days()); + assertEquals(List.of(8, 17, 18, 19), tw.hours()); } { - TimeWindow fz = TimeWindow.from("sat,", "8,17-19", "UTC"); - assertEquals(asList(SATURDAY), fz.days()); - assertEquals(asList(8, 17, 18, 19), fz.hours()); + TimeWindow tw = TimeWindow.from("sat,", "8,17-19", "UTC", "", ""); + assertEquals(List.of(SATURDAY), tw.days()); + assertEquals(List.of(8, 17, 18, 19), tw.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 tw = TimeWindow.from("tue,sat", "0,3,7,10", "UTC", "", ""); + assertEquals(List.of(TUESDAY, SATURDAY), tw.days()); + assertEquals(List.of(0, 3, 7, 10), tw.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()); + TimeWindow tw = TimeWindow.from("mon,wed-thu", "0,17-19", "UTC", "", ""); + assertEquals(List.of(MONDAY, WEDNESDAY, THURSDAY), tw.days()); + assertEquals(List.of(0, 17, 18, 19), tw.hours()); + } + { // Empty results in default values + TimeWindow tw = TimeWindow.from("", "", "", "", ""); + assertEquals(List.of(DayOfWeek.values()), tw.days()); + assertEquals(IntStream.rangeClosed(0, 23).boxed().collect(Collectors.toList()), tw.hours()); + assertEquals("UTC", tw.zone().getId()); } { // 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()); + TimeWindow tw = TimeWindow.from("monday,wednesday-thursday", "0,17-19", "UTC", "", ""); + assertEquals(List.of(MONDAY, WEDNESDAY, THURSDAY), tw.days()); + assertEquals(List.of(0, 17, 18, 19), tw.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()); + TimeWindow tw = TimeWindow.from("mon,wed-thu,mon", "3,1-4", "UTC", "", ""); + assertEquals(List.of(MONDAY, WEDNESDAY, THURSDAY), tw.days()); + assertEquals(List.of(1, 2, 3, 4), tw.hours()); } } @@ -99,7 +137,6 @@ public class TimeWindowTest { 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'"); @@ -107,16 +144,29 @@ public class TimeWindowTest { 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'"); + + // Invalid date range + assertInvalidDateRange("", "foo", "bar", "Could not parse date range 'foo' and 'bar'"); + assertInvalidDateRange("", "2022-01-15", "2022-01-01", "Invalid date range: start date 2022-01-15 is after end date 2022-01-01"); + assertInvalidDateRange("wed", "2022-01-06", "2022-01-09", "Invalid day: date range [2022-01-06, 2022-01-09] does not contain WEDNESDAY"); + assertInvalidDateRange("mon-sun", "2022-01-03", "2022-01-07", "Invalid day: date range [2022-01-03, 2022-01-07] does not contain SATURDAY"); + } + + private static void assertOutside(TimeWindow window, Instant instant) { + assertFalse("Instant " + instant + " is not in window", window.includes(instant)); + } + + private static void assertInside(TimeWindow window, Instant instant) { + assertTrue("Instant " + instant + " is in window", window.includes(instant)); } private static void assertInvalidZone(String zoneSpec, String exceptionMessage) { try { - TimeWindow.from("mon", "1", zoneSpec); + TimeWindow.from("mon", "1", zoneSpec, "", ""); fail("Expected exception"); } catch (IllegalArgumentException e) { assertEquals(exceptionMessage, e.getMessage()); @@ -125,7 +175,7 @@ public class TimeWindowTest { private static void assertInvalidDays(String daySpec, String exceptionMessage) { try { - TimeWindow.from(daySpec, "1", "UTC"); + TimeWindow.from(daySpec, "1", "UTC", "", ""); fail("Expected exception"); } catch (IllegalArgumentException e) { assertEquals(exceptionMessage, e.getMessage()); @@ -134,11 +184,20 @@ public class TimeWindowTest { private static void assertInvalidHours(String hourSpec, String exceptionMessage) { try { - TimeWindow.from("mon", hourSpec, "UTC"); + TimeWindow.from("mon", hourSpec, "UTC", "", ""); fail("Expected exception"); } catch (IllegalArgumentException e) { assertEquals(exceptionMessage, e.getMessage()); } } + private static void assertInvalidDateRange(String daySpec, String startDate, String endDate, String message) { + try { + TimeWindow.from(daySpec, "", "UTC", startDate, endDate); + fail("Expected exception"); + } catch (IllegalArgumentException e) { + assertEquals(message, e.getMessage()); + } + } + } diff --git a/config-model/src/main/resources/schema/deployment.rnc b/config-model/src/main/resources/schema/deployment.rnc index 51a286a13c8..819e6b79fbb 100644 --- a/config-model/src/main/resources/schema/deployment.rnc +++ b/config-model/src/main/resources/schema/deployment.rnc @@ -58,8 +58,10 @@ Upgrade = element upgrade { BlockChange = element block-change { attribute revision { xsd:boolean }? & attribute version { xsd:boolean }? & - attribute days { xsd:string } & - attribute hours { xsd:string } & + attribute days { xsd:string }? & + attribute hours { xsd:string }? & + attribute from-date { xsd:string }? & + attribute to-date { xsd:string }? & attribute time-zone { xsd:string }? } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporterTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporterTest.java index 604a42f3d19..d97f1d58043 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporterTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/maintenance/MetricsReporterTest.java @@ -554,15 +554,6 @@ public class MetricsReporterTest { assertEquals("Upgrade is overdue measure relative to window 3", Duration.ofHours(34).plusMinutes(30), metric.get()); } - @Test - public void overdue_upgrade_completely_blocked() { - ApplicationPackage pkg = new ApplicationPackageBuilder().region("us-west-1") - .blockChange(false, true, "mon-sun", "0-23", "CET") - .build(); - Instant mondayNight = Instant.parse("2021-12-13T23:00:00.00Z"); - assertEquals(Duration.ZERO, MetricsReporter.overdueUpgradeDuration(mondayNight, pkg.deploymentSpec().requireInstance("default"))); - } - private void assertNodeCount(String metric, int n, Version version) { long nodeCount = metrics.getMetric((dimensions) -> version.toFullString().equals(dimensions.get("currentVersion")), metric) .stream() |