diff --git a/src/main/java/hudson/remoting/Engine.java b/src/main/java/hudson/remoting/Engine.java index 04a631fbd..8889e6f7e 100644 --- a/src/main/java/hudson/remoting/Engine.java +++ b/src/main/java/hudson/remoting/Engine.java @@ -52,6 +52,8 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.security.interfaces.RSAPublicKey; +import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; @@ -100,6 +102,7 @@ import org.jenkinsci.remoting.protocol.cert.DelegatingX509ExtendedTrustManager; import org.jenkinsci.remoting.protocol.cert.PublicKeyMatchingX509ExtendedTrustManager; import org.jenkinsci.remoting.protocol.impl.ConnectionRefusalException; +import org.jenkinsci.remoting.util.DurationFormatter; import org.jenkinsci.remoting.util.KeyUtils; import org.jenkinsci.remoting.util.VersionNumber; import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; @@ -193,6 +196,10 @@ public Thread newThread(@NonNull final Runnable r) { private boolean noReconnect = false; + private Duration noReconnectAfter; + + private Instant firstAttempt; + /** * Determines whether the socket will have {@link Socket#setKeepAlive(boolean)} set or not. * @@ -412,6 +419,10 @@ public void setNoReconnect(boolean noReconnect) { this.noReconnect = noReconnect; } + public void setNoReconnectAfter(@CheckForNull Duration noReconnectAfter) { + this.noReconnectAfter = noReconnectAfter; + } + /** * Determines if JNLPAgentEndpointResolver will not perform certificate validation in the HTTPs mode. * @@ -740,9 +751,14 @@ public void closeRead() throws IOException { if (noReconnect) { return; } + firstAttempt = Instant.now(); events.onDisconnect(); while (true) { // TODO refactor various sleep statements into a common method + if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { + events.status("Bailing out after " + DurationFormatter.format(noReconnectAfter)); + return; + } TimeUnit.SECONDS.sleep(10); // Unlike JnlpAgentEndpointResolver, we do not use $jenkins/tcpSlaveAgentListener/, as that will be a 404 if the TCP port is disabled. URL ping = new URL(hudsonUrl, "login"); @@ -795,6 +811,7 @@ private void innerRun(IOHub hub, SSLContext context, ExecutorService service) { try { boolean first = true; + firstAttempt = Instant.now(); while(true) { if(first) { first = false; @@ -802,7 +819,10 @@ private void innerRun(IOHub hub, SSLContext context, ExecutorService service) { if(noReconnect) return; // exit } - + if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { + events.status("Bailing out after " + DurationFormatter.format(noReconnectAfter)); + return; + } events.status("Locating server among " + candidateUrls); final JnlpAgentEndpoint endpoint; try { @@ -915,7 +935,7 @@ private void innerRun(IOHub hub, SSLContext context, ExecutorService service) { } if(noReconnect) return; // exit - + firstAttempt = Instant.now(); events.onDisconnect(); // try to connect back to the server every 10 secs. @@ -938,7 +958,7 @@ private JnlpEndpointResolver createEndpointResolver(List jenkinsUrls, St events.error(e); } resolver = new JnlpAgentEndpointResolver(jenkinsUrls, agentName, credentials, proxyCredentials, tunnel, - sslSocketFactory, disableHttpsCertValidation); + sslSocketFactory, disableHttpsCertValidation, noReconnectAfter); } else { resolver = new JnlpAgentEndpointConfigurator(directConnection, instanceIdentity, protocols, proxyCredentials); } diff --git a/src/main/java/hudson/remoting/Launcher.java b/src/main/java/hudson/remoting/Launcher.java index 65a3854e3..125b9a10a 100644 --- a/src/main/java/hudson/remoting/Launcher.java +++ b/src/main/java/hudson/remoting/Launcher.java @@ -27,8 +27,12 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.remoting.Channel.Mode; +import java.time.Duration; +import java.time.Instant; +import org.jenkinsci.remoting.DurationOptionHandler; import org.jenkinsci.remoting.engine.JnlpAgentEndpointResolver; import org.jenkinsci.remoting.engine.WorkDirManager; +import org.jenkinsci.remoting.util.DurationFormatter; import org.jenkinsci.remoting.util.PathUtils; import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; import org.kohsuke.args4j.Argument; @@ -249,6 +253,9 @@ public void setConnectTo(String target) { @Option(name="-noReconnect",aliases="-noreconnect",usage="Doesn't try to reconnect when a communication fail, and exit instead") public boolean noReconnect = false; + @Option(name="-noReconnectAfter",usage = "Bail out after the given time after the first attempt to reconnect", handler = DurationOptionHandler.class, forbids = "-noReconnect") + public Duration noReconnectAfter; + @Option(name = "-noKeepAlive", usage = "Disable TCP socket keep alive on connection to the controller.") public boolean noKeepAlive = false; @@ -682,6 +689,7 @@ private List parseJnlpArguments() throws ParserConfigurationException, S throw new IOException("-jnlpCredentials and -secret are mutually exclusive"); } } + Instant firstAttempt = Instant.now(); while (true) { URLConnection con = null; try { @@ -742,7 +750,9 @@ private List parseJnlpArguments() throws ParserConfigurationException, S } catch (IOException e) { if (this.noReconnect) throw new IOException("Failed to obtain " + agentJnlpURL, e); - + if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { + throw new IOException("Failed to obtain " + agentJnlpURL + " after " + DurationFormatter.format(noReconnectAfter), e); + } System.err.println("Failed to obtain "+ agentJnlpURL); e.printStackTrace(System.err); System.err.println("Waiting 10 seconds before retry"); @@ -1026,6 +1036,7 @@ private Engine createEngine() throws IOException { engine.setJarCache(new FileSystemJarCache(jarCache, true)); } engine.setNoReconnect(noReconnect); + engine.setNoReconnectAfter(noReconnectAfter); engine.setKeepAlive(!noKeepAlive); if (noCertificateCheck) { diff --git a/src/main/java/hudson/remoting/Util.java b/src/main/java/hudson/remoting/Util.java index c3ca4870a..32f880d5c 100644 --- a/src/main/java/hudson/remoting/Util.java +++ b/src/main/java/hudson/remoting/Util.java @@ -1,7 +1,10 @@ package hudson.remoting; +import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.Duration; +import java.time.Instant; import org.jenkinsci.remoting.util.PathUtils; import org.kohsuke.accmod.Restricted; import org.kohsuke.accmod.restrictions.NoExternalUse; @@ -114,6 +117,13 @@ static public String getVersion() { return version; } + public static boolean shouldBailOut(@NonNull Instant firstAttempt, @CheckForNull Duration noReconnectAfter) { + if (noReconnectAfter == null) { + return false; + } + return Duration.between(firstAttempt, Instant.now()).compareTo(noReconnectAfter) > 0; + } + private Util() { } diff --git a/src/main/java/org/jenkinsci/remoting/DurationOptionHandler.java b/src/main/java/org/jenkinsci/remoting/DurationOptionHandler.java new file mode 100644 index 000000000..baf0a2ab2 --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/DurationOptionHandler.java @@ -0,0 +1,63 @@ +/* + * The MIT License + * + * Copyright (c) 2024, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.remoting; + +import java.time.Duration; +import org.jenkinsci.remoting.util.DurationFormatter; +import org.jenkinsci.remoting.util.DurationStyle; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; +import org.kohsuke.args4j.CmdLineException; +import org.kohsuke.args4j.CmdLineParser; +import org.kohsuke.args4j.OptionDef; +import org.kohsuke.args4j.spi.OptionHandler; +import org.kohsuke.args4j.spi.Parameters; +import org.kohsuke.args4j.spi.Setter; + +/** + * Parses a string like 1s, 2m, 3h, 4d into a {@link Duration}. + */ +@Restricted(NoExternalUse.class) +public class DurationOptionHandler extends OptionHandler { + public DurationOptionHandler(CmdLineParser parser, OptionDef option, Setter setter) { + super(parser, option, setter); + } + + @Override + public int parseArguments(Parameters params) throws CmdLineException { + setter.addValue(DurationStyle.detectAndParse(params.getParameter(0))); + return 1; + } + + @Override + public String getDefaultMetaVariable() { + return "DURATION"; + } + + @Override + protected String print(Duration v) { + return DurationFormatter.format(v); + } + +} diff --git a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java index ccb39ec71..1b2d60b78 100644 --- a/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java +++ b/src/main/java/org/jenkinsci/remoting/engine/JnlpAgentEndpointResolver.java @@ -29,6 +29,10 @@ import hudson.remoting.Engine; import hudson.remoting.Launcher; import hudson.remoting.NoProxyEvaluator; +import hudson.remoting.Util; +import java.time.Duration; +import java.time.Instant; +import org.jenkinsci.remoting.util.DurationFormatter; import org.jenkinsci.remoting.util.VersionNumber; import org.jenkinsci.remoting.util.https.NoCheckHostnameVerifier; import org.kohsuke.accmod.Restricted; @@ -100,6 +104,8 @@ public class JnlpAgentEndpointResolver extends JnlpEndpointResolver { private HostnameVerifier hostnameVerifier; + private Duration noReconnectAfter; + /** * If specified, only the protocols from the list will be tried during the connection. * The option provides protocol names, but the order of the check is defined internally and cannot be changed. @@ -110,7 +116,7 @@ public class JnlpAgentEndpointResolver extends JnlpEndpointResolver { System.getProperty(JnlpAgentEndpointResolver.class.getName() + ".protocolNamesToTry"); public JnlpAgentEndpointResolver(@NonNull List jenkinsUrls, String agentName, String credentials, String proxyCredentials, - String tunnel, SSLSocketFactory sslSocketFactory, boolean disableHttpsCertValidation) { + String tunnel, SSLSocketFactory sslSocketFactory, boolean disableHttpsCertValidation, Duration noReconnectAfter) { this.jenkinsUrls = new ArrayList<>(jenkinsUrls); this.agentName = agentName; this.credentials = credentials; @@ -118,6 +124,7 @@ public JnlpAgentEndpointResolver(@NonNull List jenkinsUrls, String agent this.tunnel = tunnel; this.sslSocketFactory = sslSocketFactory; setDisableHttpsCertValidation(disableHttpsCertValidation); + this.noReconnectAfter = noReconnectAfter; } public SSLSocketFactory getSslSocketFactory() { @@ -401,8 +408,13 @@ public void waitForReady() throws InterruptedException { String oldName = t.getName(); try { int retries = 0; + Instant firstAttempt = Instant.now(); while (true) { // TODO refactor various sleep statements into a common method + if (Util.shouldBailOut(firstAttempt, noReconnectAfter)) { + LOGGER.info("Bailing out after " + DurationFormatter.format(noReconnectAfter)); + return; + } Thread.sleep(1000 * 10); // Jenkins top page might be read-protected. see http://www.nabble // .com/more-lenient-retry-logic-in-Engine.waitForServerToBack-td24703172.html diff --git a/src/main/java/org/jenkinsci/remoting/util/DurationFormatter.java b/src/main/java/org/jenkinsci/remoting/util/DurationFormatter.java new file mode 100644 index 000000000..d9d98de7e --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/util/DurationFormatter.java @@ -0,0 +1,72 @@ +/* + * The MIT License + * + * Copyright (c) 2024, CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ +package org.jenkinsci.remoting.util; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Formats a {@link Duration} into a human-readable string. + */ +@Restricted(NoExternalUse.class) +public final class DurationFormatter { + private DurationFormatter(){} + + public static String format(Duration d) { + StringBuilder sb = new StringBuilder(); + boolean first = true; + long days = d.toDays(); + if (days > 0) { + first = formatDurationPart(true, sb, days, "day"); + d = d.minus(days, ChronoUnit.DAYS); + } + long hours = d.toHours(); + if (hours > 0) { + first = formatDurationPart(first, sb, hours, "hour"); + d = d.minus(hours, ChronoUnit.HOURS); + } + long minutes = d.toMinutes(); + if (minutes > 0) { + first = formatDurationPart(first, sb, minutes, "minute"); + d = d.minus(minutes, ChronoUnit.MINUTES); + } + long seconds = d.getSeconds(); + if (seconds > 0) { + formatDurationPart(first, sb, seconds, "second"); + } + return sb.toString(); + } + + private static boolean formatDurationPart(boolean first, StringBuilder sb, long amount, String unit) { + if (!first) { + sb.append(", "); + } else { + first = false; + } + sb.append(amount).append(" ").append(unit).append(amount > 1 ? "s" : ""); + return first; + } +} diff --git a/src/main/java/org/jenkinsci/remoting/util/DurationStyle.java b/src/main/java/org/jenkinsci/remoting/util/DurationStyle.java new file mode 100644 index 000000000..3a8e88b65 --- /dev/null +++ b/src/main/java/org/jenkinsci/remoting/util/DurationStyle.java @@ -0,0 +1,269 @@ +/* + * Copyright 2012-2023 the original author or authors. + * + * 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 + * + * https://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.jenkinsci.remoting.util; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Locale; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +/** + * Duration format styles. + * Copied from the Spring Boot project. + * + * + * @author Phillip Webb + * @author Valentine Wu + */ +@Restricted(NoExternalUse.class) +public enum DurationStyle { + + /** + * Simple formatting, for example '1s'. + */ + SIMPLE("^([+-]?\\d+)([a-zA-Z]{0,2})$") { + + @Override + public Duration parse(String value, ChronoUnit unit) { + try { + Matcher matcher = matcher(value); + if (!matcher.matches()) { + throw new IllegalStateException("Does not match simple duration pattern"); + } + String suffix = matcher.group(2); + return ((suffix != null && !suffix.isEmpty()) ? Unit.fromSuffix(suffix) : Unit.fromChronoUnit(unit)) + .parse(matcher.group(1)); + } + catch (Exception ex) { + throw new IllegalArgumentException("'" + value + "' is not a valid simple duration", ex); + } + } + + @Override + public String print(Duration value, ChronoUnit unit) { + return Unit.fromChronoUnit(unit).print(value); + } + + }, + + /** + * ISO-8601 formatting. + */ + ISO8601("^[+-]?[pP].*$") { + + @Override + public Duration parse(String value, ChronoUnit unit) { + try { + return Duration.parse(value); + } + catch (Exception ex) { + throw new IllegalArgumentException("'" + value + "' is not a valid ISO-8601 duration", ex); + } + } + + @Override + public String print(Duration value, ChronoUnit unit) { + return value.toString(); + } + + }; + + private final Pattern pattern; + + DurationStyle(String pattern) { + this.pattern = Pattern.compile(pattern); + } + + protected final boolean matches(String value) { + return this.pattern.matcher(value).matches(); + } + + protected final Matcher matcher(String value) { + return this.pattern.matcher(value); + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @return a duration + */ + public Duration parse(String value) { + return parse(value, null); + } + + /** + * Parse the given value to a duration. + * @param value the value to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return a duration + */ + public abstract Duration parse(String value, ChronoUnit unit); + + /** + * Print the specified duration. + * @param value the value to print + * @return the printed result + */ + public String print(Duration value) { + return print(value, null); + } + + /** + * Print the specified duration using the given unit. + * @param value the value to print + * @param unit the value to use for printing + * @return the printed result + */ + public abstract String print(Duration value, ChronoUnit unit); + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value) { + return detectAndParse(value, null); + } + + /** + * Detect the style then parse the value to return a duration. + * @param value the value to parse + * @param unit the duration unit to use if the value doesn't specify one ({@code null} + * will default to ms) + * @return the parsed duration + * @throws IllegalArgumentException if the value is not a known style or cannot be + * parsed + */ + public static Duration detectAndParse(String value, ChronoUnit unit) { + return detect(value).parse(value, unit); + } + + /** + * Detect the style from the given source value. + * @param value the source value + * @return the duration style + * @throws IllegalArgumentException if the value is not a known style + */ + public static DurationStyle detect(String value) { + if (value == null) { + throw new IllegalArgumentException("Value must not be null"); + } + for (DurationStyle candidate : values()) { + if (candidate.matches(value)) { + return candidate; + } + } + throw new IllegalArgumentException("'" + value + "' is not a valid duration"); + } + + /** + * Units that we support. + */ + enum Unit { + + /** + * Nanoseconds. + */ + NANOS(ChronoUnit.NANOS, "ns", Duration::toNanos), + + /** + * Microseconds. + */ + MICROS(ChronoUnit.MICROS, "µs", duration -> duration.toNanos() / 1000L), + + /** + * Milliseconds. + */ + MILLIS(ChronoUnit.MILLIS, "ms", Duration::toMillis), + + /** + * Seconds. + */ + SECONDS(ChronoUnit.SECONDS, "s", Duration::getSeconds), + + /** + * Minutes. + */ + MINUTES(ChronoUnit.MINUTES, "m", Duration::toMinutes), + + /** + * Hours. + */ + HOURS(ChronoUnit.HOURS, "h", Duration::toHours), + + /** + * Days. + */ + DAYS(ChronoUnit.DAYS, "d", Duration::toDays); + + private final ChronoUnit chronoUnit; + + private final String suffix; + + private final Function longValue; + + Unit(ChronoUnit chronoUnit, String suffix, Function toUnit) { + this.chronoUnit = chronoUnit; + this.suffix = suffix; + this.longValue = toUnit; + } + + public Duration parse(String value) { + return Duration.of(Long.parseLong(value), this.chronoUnit); + } + + public String print(Duration value) { + return longValue(value) + this.suffix; + } + + public long longValue(Duration value) { + return this.longValue.apply(value); + } + + public static Unit fromChronoUnit(ChronoUnit chronoUnit) { + if (chronoUnit == null) { + return Unit.MILLIS; + } + for (Unit candidate : values()) { + if (candidate.chronoUnit == chronoUnit) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown unit " + chronoUnit); + } + + @SuppressFBWarnings(value = "IMPROPER_UNICODE", justification = "not security sensitive") + public static Unit fromSuffix(String suffix) { + for (Unit candidate : values()) { + if (candidate.suffix.equals(suffix.toLowerCase(Locale.ROOT))) { + return candidate; + } + } + throw new IllegalArgumentException("Unknown unit '" + suffix + "'"); + } + + } + +} diff --git a/src/test/java/org/jenkinsci/remoting/util/DurationFormatterTest.java b/src/test/java/org/jenkinsci/remoting/util/DurationFormatterTest.java new file mode 100644 index 000000000..b74bc7766 --- /dev/null +++ b/src/test/java/org/jenkinsci/remoting/util/DurationFormatterTest.java @@ -0,0 +1,22 @@ +package org.jenkinsci.remoting.util; + +import static org.junit.Assert.assertEquals; + +import java.time.Duration; +import org.junit.Test; + +public class DurationFormatterTest { + @Test + public void typical() { + assertEquals("1 second", DurationFormatter.format(Duration.ofSeconds(1))); + assertEquals("2 seconds", DurationFormatter.format(Duration.ofSeconds(2))); + assertEquals("1 day, 2 seconds", DurationFormatter.format(Duration.ofDays(1).plus(Duration.ofSeconds(2)))); + assertEquals("2 days, 3 hours, 2 seconds", DurationFormatter.format(Duration.ofDays(2).plus(Duration.ofHours(3)).plus(Duration.ofSeconds(2)))); + assertEquals("2 days, 3 hours, 1 minute, 2 seconds", DurationFormatter.format( + Duration.ofDays(2) + .plus(Duration.ofHours(3)) + .plus(Duration.ofMinutes(1)) + .plus(Duration.ofSeconds(2) + ))); + } +} diff --git a/src/test/java/org/jenkinsci/remoting/util/DurationStyleTest.java b/src/test/java/org/jenkinsci/remoting/util/DurationStyleTest.java new file mode 100644 index 000000000..7814f5f06 --- /dev/null +++ b/src/test/java/org/jenkinsci/remoting/util/DurationStyleTest.java @@ -0,0 +1,21 @@ +package org.jenkinsci.remoting.util; + +import static org.junit.Assert.assertEquals; + +import java.time.Duration; +import org.junit.Test; + +public class DurationStyleTest { + @Test + public void typical() { + assertEquals(Duration.ofSeconds(1), DurationStyle.detectAndParse("1s")); + assertEquals(Duration.ofMinutes(2), DurationStyle.detectAndParse("2m")); + assertEquals(Duration.ofHours(3), DurationStyle.detectAndParse("3h")); + assertEquals(Duration.ofDays(4), DurationStyle.detectAndParse("4d")); + } + + @Test + public void negative() { + assertEquals(Duration.ofSeconds(1).negated(), DurationStyle.detectAndParse("-1s")); + } +}