diff --git a/README.md b/README.md index c0612c47..688c7b37 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ With their feedback, plugin improvement is possible. Special thanks to: @[rolaca11](https://github.com/rolaca11), @[stephanebastian](https://github.com/stephanebastian), @[TapaiBalazs](https://github.com/TapaiBalazs), +@[thebignet](https://github.com/thebignet) @[tngwoerleij](https://github.com/tngwoerleij), @[trohr](https://github.com/trohr), @[xehonk](https://github.com/xehonk) diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index cd633742..5b351723 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -51,6 +51,7 @@ configurations["intTestRuntimeOnly"] dependencies { implementation(gradleApi()) + implementation("io.github.resilience4j:resilience4j-retry:2.1.0") implementation("org.apache.httpcomponents.client5:httpclient5:5.2.1") implementation("org.apache.commons:commons-compress:1.23.0") implementation("org.json:json:20230227") diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/FrontendGradlePlugin.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/FrontendGradlePlugin.java index 7fd268dc..6465ce47 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/FrontendGradlePlugin.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/FrontendGradlePlugin.java @@ -6,6 +6,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Set; import org.gradle.api.GradleException; import org.gradle.api.Plugin; @@ -97,6 +98,11 @@ public class FrontendGradlePlugin implements Plugin { */ public static final String DEFAULT_INSTALL_SCRIPT = "install"; + /** + * Maximum number of attempts to download a file. + */ + public static final int DEFAULT_MAX_DOWNLOAD_ATTEMPTS = 1; + /** * Name of the task that installs a Node.js distribution. */ @@ -112,6 +118,17 @@ public class FrontendGradlePlugin implements Plugin { */ public static final String DEFAULT_NODE_DISTRIBUTION_URL_ROOT = "https://nodejs.org/dist/"; + /** + * HTTP statuses that should trigger another download attempt. + */ + public static final Set DEFAULT_RETRY_HTTP_STATUSES = Set.of(408, 429, 500, 502, 503, 504); + + public static final int DEFAULT_RETRY_INITIAL_INTERVAL_MS = 1000; + + public static final double DEFAULT_RETRY_INTERVAL_MULTIPLIER = 2; + + public static final int DEFAULT_RETRY_MAX_INTERVAL_MS = 30000; + public static final String GRADLE_ASSEMBLE_TASK_NAME = LifecycleBasePlugin.ASSEMBLE_TASK_NAME; public static final String GRADLE_CHECK_TASK_NAME = LifecycleBasePlugin.CHECK_TASK_NAME; @@ -188,6 +205,11 @@ public void apply(final Project project) { frontendExtension.getPackageJsonDirectory().convention(project.getLayout().getProjectDirectory()); frontendExtension.getHttpProxyPort().convention(DEFAULT_HTTP_PROXY_PORT); frontendExtension.getHttpsProxyPort().convention(DEFAULT_HTTPS_PROXY_PORT); + frontendExtension.getMaxDownloadAttempts().convention(DEFAULT_MAX_DOWNLOAD_ATTEMPTS); + frontendExtension.getRetryHttpStatuses().convention(DEFAULT_RETRY_HTTP_STATUSES); + frontendExtension.getRetryInitialIntervalMs().convention(DEFAULT_RETRY_INITIAL_INTERVAL_MS); + frontendExtension.getRetryIntervalMultiplier().convention(DEFAULT_RETRY_INTERVAL_MULTIPLIER); + frontendExtension.getRetryMaxIntervalMs().convention(DEFAULT_RETRY_MAX_INTERVAL_MS); frontendExtension .getCacheDirectory() .convention(project.getLayout().getProjectDirectory().dir(DEFAULT_CACHE_DIRECTORY_NAME)); @@ -341,6 +363,11 @@ protected void configureInstallNodeTask(final InstallNodeTask task, final String .platform(getBeanOrFail(beanRegistryId, PlatformProvider.class).getPlatform()) .build()) .toFile())); + task.getMaxDownloadAttempts().set(extension.getMaxDownloadAttempts()); + task.getRetryHttpStatuses().set(extension.getRetryHttpStatuses()); + task.getRetryInitialIntervalMs().set(extension.getRetryInitialIntervalMs()); + task.getRetryIntervalMultiplier().set(extension.getRetryIntervalMultiplier()); + task.getRetryMaxIntervalMs().set(extension.getRetryMaxIntervalMs()); task.setOnlyIf(t -> !extension.getNodeDistributionProvided().get()); } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/FrontendException.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/FrontendException.java index 22f91210..04c04280 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/FrontendException.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/FrontendException.java @@ -8,4 +8,8 @@ public abstract class FrontendException extends Exception { protected FrontendException(final String message) { super(message); } + + protected FrontendException(final Throwable throwable) { + super(throwable); + } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistribution.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistribution.java index b122d564..fd0ca71f 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistribution.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistribution.java @@ -40,6 +40,7 @@ public void execute(final DeployDistributionCommand command) throws UnsupportedDistributionArchiveException, ArchiverException, IOException { // Explodes the archive final Path temporaryDirectoryPath = fileManager.createDirectory(command.getTemporaryDirectoryPath()); + logger.info("Exploding distribution into '{}'", temporaryDirectoryPath); final Path distributionFilePath = command.getDistributionFilePath(); archiverProvider @@ -66,8 +67,5 @@ public void execute(final DeployDistributionCommand command) distributionRootDirectoryPath = temporaryDirectoryPath; } fileManager.moveFileTree(distributionRootDirectoryPath, installDirectoryPath); - - logger.info("Removing explode directory '{}'", temporaryDirectoryPath); - fileManager.deleteIfExists(temporaryDirectoryPath); } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResource.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResource.java index 2555800f..f6e95a6b 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResource.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResource.java @@ -1,12 +1,16 @@ package org.siouan.frontendgradleplugin.domain.installer; import java.io.IOException; -import java.net.URL; +import java.net.Proxy; import java.nio.channels.FileChannel; import java.nio.channels.ReadableByteChannel; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import io.github.resilience4j.core.IntervalFunction; +import io.github.resilience4j.retry.Retry; +import io.github.resilience4j.retry.RetryConfig; +import io.github.resilience4j.retry.event.RetryEvent; import lombok.RequiredArgsConstructor; import org.siouan.frontendgradleplugin.domain.ChannelProvider; import org.siouan.frontendgradleplugin.domain.FileManager; @@ -19,6 +23,8 @@ @RequiredArgsConstructor public class DownloadResource { + private static final String RETRY_ID = DownloadResource.class.getSimpleName(); + private final FileManager fileManager; private final ChannelProvider channelProvider; @@ -32,45 +38,92 @@ public class DownloadResource { * the caller to ensure the temporary file is writable, and the directory receiving the destination file exists and * is writable. * - * @param downloadResourceCommand Command providing download parameters. + * @param command Command providing download parameters. * @throws IOException If the resource transfer failed, or the resource could not be written in the temporary file, * or moved to the destination file. In this case, any temporary file created is removed. - * @throws ResourceDownloadException If the distribution download failed. + * @throws ResourceDownloadException If the resource download failed . */ - public void execute(final DownloadResourceCommand downloadResourceCommand) - throws IOException, ResourceDownloadException { - final URL resourceUrl = downloadResourceCommand.getResourceUrl(); - final ProxySettings proxySettings = downloadResourceCommand.getProxySettings(); + public void execute(final DownloadResourceCommand command) throws IOException, ResourceDownloadException { + // Configure retry with exponential backoff. + final RetrySettings retrySettings = command.getRetrySettings(); + final RetryConfig retryConfig = RetryConfig + .custom() + .maxAttempts(retrySettings.getMaxDownloadAttempts()) + .intervalFunction(IntervalFunction.ofExponentialBackoff(retrySettings.getRetryInitialIntervalMs(), + retrySettings.getRetryIntervalMultiplier(), retrySettings.getRetryMaxIntervalMs())) + .retryExceptions(RetryableResourceDownloadException.class) + .build(); + final Retry retry = Retry.of(RETRY_ID, retryConfig); + retry + .getEventPublisher() + .onRetry(event -> logAttemptFailure(retrySettings, event)) + .onError(event -> logAttemptFailure(retrySettings, event)); + + // Download resource. + try { + Retry.decorateCheckedRunnable(retry, () -> attemptDownload(command)).run(); + } catch (final Throwable e) { + // At this point, a single type of exception is likely to be catched. But in case of a bug, type casting may + // fail and no information will be logged about the real exception catched. That's why logging the exception + // is a precondition to ease troubleshooting with end-users. + logger.debug("All download attempts failed", e); + throw (ResourceDownloadException) e; + } + + fileManager.move(command.getTemporaryFilePath(), command.getDestinationFilePath(), + StandardCopyOption.REPLACE_EXISTING); + } - if (proxySettings == null) { - logger.info("Downloading resource at '{}' (proxy: DIRECT)", downloadResourceCommand.getResourceUrl()); + private void attemptDownload(final DownloadResourceCommand command) throws ResourceDownloadException { + final ProxySettings proxySettings = command.getProxySettings(); + if (proxySettings.getProxyType() == Proxy.Type.DIRECT) { + logger.info("Downloading resource at '{}' (proxy: DIRECT)", command.getResourceUrl()); } else { - logger.info("Downloading resource at '{}' (proxy: {}/{}:{})", downloadResourceCommand.getResourceUrl(), + logger.info("Downloading resource at '{}' (proxy: {}/{}:{})", command.getResourceUrl(), proxySettings.getProxyType(), proxySettings.getProxyHost(), proxySettings.getProxyPort()); } - try (final HttpResponse response = httpClientProvider - .getInstance() - .sendGetRequest(resourceUrl, downloadResourceCommand.getServerCredentials(), proxySettings); + final HttpClient httpClient = httpClientProvider.getInstance(); + try (final HttpResponse response = httpClient.sendGetRequest(command.getResourceUrl(), + command.getServerCredentials(), command.getProxySettings()); final ReadableByteChannel resourceInputChannel = channelProvider.getReadableByteChannel( response.getInputStream()); final FileChannel resourceOutputChannel = channelProvider.getWritableFileChannelForNewFile( - downloadResourceCommand.getTemporaryFilePath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, + command.getTemporaryFilePath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) { - logger.debug("---> {}/{} {} {}", response.getProtocol(), response.getVersion(), response.getStatusCode(), + final int statusCode = response.getStatusCode(); + logger.debug("---> {}/{} {} {}", response.getProtocol(), response.getVersion(), statusCode, response.getReasonPhrase()); - if (response.getStatusCode() != 200) { - throw new ResourceDownloadException( + if (statusCode == 200) { + resourceOutputChannel.transferFrom(resourceInputChannel, 0, Long.MAX_VALUE); + } else { + final String errorMessage = "Unexpected HTTP response: " + response.getProtocol() + '/' + response.getVersion() + ' ' - + response.getStatusCode() + ' ' + response.getReasonPhrase()); + + statusCode + ' ' + response.getReasonPhrase(); + if (command.getRetrySettings().getRetryHttpStatuses().contains(statusCode)) { + throw new RetryableResourceDownloadException(errorMessage); + } else { + // Download failed because the server responded with a non-retryable HTTP status code. + throw new ResourceDownloadException(errorMessage); + } + } + } catch (final IOException e) { + try { + fileManager.deleteIfExists(command.getTemporaryFilePath()); + } catch (IOException ex) { + // Ignore this second exception as it would hide the first exception. + logger.warn("Unexpected error while deleting file after error: {}", command.getTemporaryFilePath()); } - resourceOutputChannel.transferFrom(resourceInputChannel, 0, Long.MAX_VALUE); - } catch (final IOException | ResourceDownloadException e) { - fileManager.deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); - throw e; + throw new RetryableResourceDownloadException(e); } + } - fileManager.move(downloadResourceCommand.getTemporaryFilePath(), - downloadResourceCommand.getDestinationFilePath(), StandardCopyOption.REPLACE_EXISTING); + private void logAttemptFailure(final RetrySettings retrySettings, final RetryEvent retryEvent) { + final int maxDownloadAttempts = retrySettings.getMaxDownloadAttempts(); + // Log attempt exception message just in case retry is enabled. + if (maxDownloadAttempts > 1) { + logger.info("Attempt {} of {}: {}", retryEvent.getNumberOfRetryAttempts(), maxDownloadAttempts, + retryEvent.getLastThrowable().getMessage()); + } } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommand.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommand.java index 418af865..9d9959bd 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommand.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommand.java @@ -35,6 +35,14 @@ public class DownloadResourceCommand { @EqualsAndHashCode.Include private final ProxySettings proxySettings; + /** + * Settings to retry a file download. + * + * @since 7.1.0 + */ + @EqualsAndHashCode.Include + private final RetrySettings retrySettings; + /** * Path to a temporary file. */ diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistribution.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistribution.java index d603bd62..95608317 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistribution.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistribution.java @@ -11,7 +11,7 @@ import org.siouan.frontendgradleplugin.domain.UnsupportedPlatformException; /** - * Downloads and optionally validates a distribution file. + * Downloads and validates a distribution file. * * @since 2.0.0 */ @@ -74,14 +74,20 @@ public Path execute(final GetDistributionCommand command) .resourceUrl(distributionUrl) .serverCredentials(command.getDistributionServerCredentials()) .proxySettings(command.getProxySettings()) + .retrySettings(command.getRetrySettings()) .temporaryFilePath(temporaryFilePath) .destinationFilePath(distributionFilePath) .build()); - final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - command.getTemporaryDirectoryPath(), distributionUrl, command.getDistributionServerCredentials(), - command.getProxySettings(), distributionFilePath); - validateNodeDistribution.execute(validateNodeDistributionCommand); + validateNodeDistribution.execute(ValidateNodeDistributionCommand + .builder() + .temporaryDirectoryPath(command.getTemporaryDirectoryPath()) + .distributionUrl(distributionUrl) + .distributionServerCredentials(command.getDistributionServerCredentials()) + .proxySettings(command.getProxySettings()) + .retrySettings(command.getRetrySettings()) + .distributionFilePath(distributionFilePath) + .build()); return distributionFilePath; } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionCommand.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionCommand.java index 139cf891..9e1cc098 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionCommand.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionCommand.java @@ -53,6 +53,14 @@ public class GetDistributionCommand { @EqualsAndHashCode.Include private final ProxySettings proxySettings; + /** + * Settings to retry a file download. + * + * @since 7.1.0 + */ + @EqualsAndHashCode.Include + private final RetrySettings retrySettings; + /** * Path to a temporary directory. */ diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistribution.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistribution.java index 1311209e..32487e15 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistribution.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistribution.java @@ -55,13 +55,18 @@ public void execute(final InstallNodeDistributionCommand command) final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(command.platform(), command.version(), command.distributionUrlRoot(), command.distributionUrlPathPattern(), - command.distributionServerCredentials(), command.proxySettings(), command.temporaryDirectoryPath()); + command.distributionServerCredentials(), command.proxySettings(), command.retrySettings(), + command.temporaryDirectoryPath()); final Path distributionFilePath = getDistribution.execute(getDistributionCommand); // Deploys the distribution - deployDistribution.execute(new DeployDistributionCommand(command.platform(), - command.temporaryDirectoryPath().resolve(EXTRACT_DIRECTORY_NAME), command.installDirectoryPath(), - distributionFilePath)); + final Path temporaryExplodeDirectoryPath = command.temporaryDirectoryPath().resolve(EXTRACT_DIRECTORY_NAME); + // In case deployment fails, the plugin should leave the file system in a consistent state and delete + // temporary resources created. + logger.debug("Removing explode directory '{}'", temporaryExplodeDirectoryPath); + fileManager.deleteFileTree(temporaryExplodeDirectoryPath, true); + deployDistribution.execute(new DeployDistributionCommand(command.platform(), temporaryExplodeDirectoryPath, + command.installDirectoryPath(), distributionFilePath)); logger.info("Removing distribution file '{}'", distributionFilePath); fileManager.delete(distributionFilePath); diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionCommand.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionCommand.java index 8cab6144..a3c29556 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionCommand.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionCommand.java @@ -14,6 +14,7 @@ * @param distributionUrlPathPattern Trailing path pattern to build the exact URL to download the distribution. * @param distributionServerCredentials Credentials to authenticate on the distribution server before download. * @param proxySettings Proxy settings used for downloads. + * @param retrySettings Settings to retry a file download. * @param temporaryDirectoryPath Path to a temporary directory. * @param installDirectoryPath Path to a directory where the distribution shall be installed. * @since 1.1.2 @@ -21,4 +22,4 @@ @Builder public record InstallNodeDistributionCommand(Platform platform, String version, String distributionUrlRoot, String distributionUrlPathPattern, Credentials distributionServerCredentials, ProxySettings proxySettings, - Path temporaryDirectoryPath, Path installDirectoryPath) {} + RetrySettings retrySettings, Path temporaryDirectoryPath, Path installDirectoryPath) {} diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettings.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettings.java index 4e6d1cbf..fcaf7fad 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettings.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettings.java @@ -16,6 +16,11 @@ @EqualsAndHashCode(onlyExplicitlyIncluded = true) public class ProxySettings { + /** + * Settings that request a direct connection. + */ + public static final ProxySettings NONE = ProxySettings.builder().proxyType(Proxy.Type.DIRECT).build(); + /** * Proxy host. */ diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrl.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrl.java index ee2733e2..85288c98 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrl.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrl.java @@ -34,7 +34,7 @@ public ProxySettings execute(final ResolveProxySettingsByUrlCommand command) { .nonProxyHosts(systemSettingsProvider.getNonProxyHosts()) .hostNameOrIpAddress(resourceUrl.getHost()) .build())) { - return null; + return ProxySettings.NONE; } else { final SelectProxySettingsCommand.SelectProxySettingsCommandBuilder selectProxySettingsCommandBuilder = SelectProxySettingsCommand.builder(); if (resourceProtocol.equals(HTTPS_PROTOCOL)) { @@ -55,7 +55,7 @@ public ProxySettings execute(final ResolveProxySettingsByUrlCommand command) { return selectProxySettings.execute(selectProxySettingsCommandBuilder.build()); } } else if (resourceProtocol.equals(FILE_PROTOCOL)) { - return null; + return ProxySettings.NONE; } else { throw new IllegalArgumentException("Unsupported protocol: " + resourceUrl.getProtocol()); } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResourceDownloadException.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResourceDownloadException.java index 193ccd68..30756bfa 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResourceDownloadException.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ResourceDownloadException.java @@ -3,7 +3,7 @@ import org.siouan.frontendgradleplugin.domain.FrontendException; /** - * Exception thrown when a resource download does not complete with a HTTP 200 response. + * Exception thrown when a resource download definitely failed despite eventual multiple attempts. * * @since 4.0.1 */ @@ -12,4 +12,8 @@ public class ResourceDownloadException extends FrontendException { public ResourceDownloadException(final String message) { super(message); } + + public ResourceDownloadException(final Throwable e) { + super(e); + } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettings.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettings.java new file mode 100644 index 00000000..d15fda26 --- /dev/null +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettings.java @@ -0,0 +1,48 @@ +package org.siouan.frontendgradleplugin.domain.installer; + +import java.util.Set; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * Settings to retry a file download. + * + * @since 7.1.0 + */ +@Builder +@Getter +@EqualsAndHashCode(onlyExplicitlyIncluded = true) +public class RetrySettings { + + /** + * Maximum number of attempts to download a file. + */ + @EqualsAndHashCode.Include + private final int maxDownloadAttempts; + + /** + * HTTP statuses that should trigger another download attempt. + */ + @EqualsAndHashCode.Include + private final Set retryHttpStatuses; + + /** + * Interval between the first download attempt and an eventual retry. + */ + @EqualsAndHashCode.Include + private final int retryInitialIntervalMs; + + /** + * Multiplier used to compute the intervals between retry attempts. + */ + @EqualsAndHashCode.Include + private final double retryIntervalMultiplier; + + /** + * Maximum interval between retry attempts. + */ + @EqualsAndHashCode.Include + private final int retryMaxIntervalMs; +} diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetryableResourceDownloadException.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetryableResourceDownloadException.java new file mode 100644 index 00000000..92df9df3 --- /dev/null +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/RetryableResourceDownloadException.java @@ -0,0 +1,24 @@ +package org.siouan.frontendgradleplugin.domain.installer; + +import java.io.IOException; + +/** + * Exception used to notify an attempt to download a resource failed for a reason that may trigger a retry. + * + * @since 7.1.0. + */ +class RetryableResourceDownloadException extends ResourceDownloadException { + + public RetryableResourceDownloadException(final String message) { + super(message); + } + + /** + * Wraps an I/O error, generally a connectivity issue. + * + * @param e I/O error. + */ + public RetryableResourceDownloadException(final IOException e) { + super(e); + } +} diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettings.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettings.java index c966ae5f..5d741083 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettings.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettings.java @@ -20,12 +20,13 @@ public ProxySettings execute(final SelectProxySettingsCommand command) { resolvedProxyHost = command.getProxyHost(); resolvedProxyPort = command.getProxyPort(); } - return (resolvedProxyHost == null) ? null : ProxySettings - .builder() - .proxyType(Proxy.Type.HTTP) - .proxyHost(resolvedProxyHost) - .proxyPort(resolvedProxyPort) - .credentials(command.getProxyCredentials()) - .build(); + return (resolvedProxyHost == null) ? ProxySettings.NONE + : ProxySettings + .builder() + .proxyType(Proxy.Type.HTTP) + .proxyHost(resolvedProxyHost) + .proxyPort(resolvedProxyPort) + .credentials(command.getProxyCredentials()) + .build(); } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistribution.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistribution.java index a0f8678e..8b8b33c6 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistribution.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistribution.java @@ -33,43 +33,39 @@ public class ValidateNodeDistribution { * each supported platform, resolves the expected shasum matching the distribution file name, and verifies the * actual shasum of the distribution file matches this expected shasum. * - * @param validateNodeDistributionCommand Command providing parameters to validate the distribution. + * @param command Command providing parameters to validate the distribution. * @throws ResourceDownloadException If downloading the file providing shasums fails. * @throws InvalidNodeDistributionException If the distribution is invalid. * @throws NodeDistributionShasumNotFoundException If validation cannot be done for other reason. * @throws IOException If an I/O error occurs. */ - public void execute(final ValidateNodeDistributionCommand validateNodeDistributionCommand) + public void execute(final ValidateNodeDistributionCommand command) throws InvalidNodeDistributionException, IOException, NodeDistributionShasumNotFoundException, ResourceDownloadException { - final Path shasumsFilePath = validateNodeDistributionCommand - .getTemporaryDirectoryPath() - .resolve(SHASUMS_FILE_NAME); + final Path shasumsFilePath = command.getTemporaryDirectoryPath().resolve(SHASUMS_FILE_NAME); // Resolve the URL to download the shasum file final String expectedShasum; try { - final URL shasumsFileUrl = new URL(validateNodeDistributionCommand.getDistributionUrl(), SHASUMS_FILE_NAME); + final URL shasumsFileUrl = new URL(command.getDistributionUrl(), SHASUMS_FILE_NAME); // Download the shasum file logger.debug("Downloading shasums at '{}'", shasumsFileUrl); - final Path temporaryFilePath = validateNodeDistributionCommand + final Path temporaryFilePath = command .getTemporaryDirectoryPath() .resolve(buildTemporaryFileName.execute(shasumsFilePath.getFileName().toString())); downloadResource.execute(DownloadResourceCommand .builder() .resourceUrl(shasumsFileUrl) - .serverCredentials(validateNodeDistributionCommand.getDistributionServerCredentials()) - .proxySettings(validateNodeDistributionCommand.getProxySettings()) + .serverCredentials(command.getDistributionServerCredentials()) + .proxySettings(command.getProxySettings()) + .retrySettings(command.getRetrySettings()) .temporaryFilePath(temporaryFilePath) .destinationFilePath(shasumsFilePath) .build()); // Verify the distribution integrity logger.info("Verifying distribution integrity"); - final String distributionFileName = validateNodeDistributionCommand - .getDistributionFilePath() - .getFileName() - .toString(); + final String distributionFileName = command.getDistributionFilePath().getFileName().toString(); expectedShasum = readNodeDistributionShasum .execute(ReadNodeDistributionShasumCommand .builder() @@ -81,7 +77,7 @@ public void execute(final ValidateNodeDistributionCommand validateNodeDistributi fileManager.deleteIfExists(shasumsFilePath); } - if (!hashFile.execute(validateNodeDistributionCommand.getDistributionFilePath()).equals(expectedShasum)) { + if (!hashFile.execute(command.getDistributionFilePath()).equals(expectedShasum)) { throw new InvalidNodeDistributionException(); } } diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionCommand.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionCommand.java index 9519bbf4..08847fbf 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionCommand.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionCommand.java @@ -41,6 +41,14 @@ public class ValidateNodeDistributionCommand { @EqualsAndHashCode.Include private final ProxySettings proxySettings; + /** + * Settings to retry a file download. + * + * @since 7.1.0 + */ + @EqualsAndHashCode.Include + private final RetrySettings retrySettings; + /** * Path to the distribution archive. */ diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/FrontendExtension.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/FrontendExtension.java index 6d559986..57cef05a 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/FrontendExtension.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/FrontendExtension.java @@ -5,6 +5,7 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.model.ObjectFactory; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; /** * Extension providing configuration properties for frontend tasks. @@ -143,6 +144,41 @@ public class FrontendExtension { */ private final Property httpsProxyPassword; + /** + * Maximum number of attempts to download a file. + * + * @since 7.1.0 + */ + private final Property maxDownloadAttempts; + + /** + * HTTP statuses that should trigger another download attempt. + * + * @since 7.1.0 + */ + private final SetProperty retryHttpStatuses; + + /** + * Interval between the first download attempt and an eventual retry. + * + * @since 7.1.0 + */ + private final Property retryInitialIntervalMs; + + /** + * Multiplier used to compute the intervals between retry attempts. + * + * @since 7.1.0 + */ + private final Property retryIntervalMultiplier; + + /** + * Maximum interval between retry attempts. + * + * @since 7.1.0 + */ + private final Property retryMaxIntervalMs; + /** * Directory where the plugin caches some common files for multiple tasks. * @@ -205,6 +241,11 @@ public FrontendExtension(final ObjectFactory objectFactory) { httpsProxyPort = objectFactory.property(Integer.class); httpsProxyUsername = objectFactory.property(String.class); httpsProxyPassword = objectFactory.property(String.class); + maxDownloadAttempts = objectFactory.property(Integer.class); + retryHttpStatuses = objectFactory.setProperty(Integer.class); + retryInitialIntervalMs = objectFactory.property(Integer.class); + retryIntervalMultiplier = objectFactory.property(Double.class); + retryMaxIntervalMs = objectFactory.property(Integer.class); cacheDirectory = objectFactory.directoryProperty(); internalPackageJsonFile = objectFactory.fileProperty(); internalPackageManagerSpecificationFile = objectFactory.fileProperty(); diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/InstallNodeTask.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/InstallNodeTask.java index 8aaabf63..d17b80a2 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/InstallNodeTask.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/gradle/InstallNodeTask.java @@ -10,6 +10,7 @@ import org.gradle.api.file.RegularFileProperty; import org.gradle.api.model.ObjectFactory; import org.gradle.api.provider.Property; +import org.gradle.api.provider.SetProperty; import org.gradle.api.tasks.Input; import org.gradle.api.tasks.Internal; import org.gradle.api.tasks.OutputFile; @@ -26,6 +27,7 @@ import org.siouan.frontendgradleplugin.domain.installer.ResolveProxySettingsByUrl; import org.siouan.frontendgradleplugin.domain.installer.ResolveProxySettingsByUrlCommand; import org.siouan.frontendgradleplugin.domain.installer.ResourceDownloadException; +import org.siouan.frontendgradleplugin.domain.installer.RetrySettings; import org.siouan.frontendgradleplugin.domain.installer.UnsupportedDistributionArchiveException; import org.siouan.frontendgradleplugin.domain.installer.archiver.ArchiverException; import org.siouan.frontendgradleplugin.infrastructure.bean.BeanRegistryException; @@ -93,56 +95,91 @@ public class InstallNodeTask extends DefaultTask { * * @since 5.0.0 */ - protected final Property httpProxyHost; + private final Property httpProxyHost; /** * Proxy port used to download resources with HTTP protocol. * * @since 5.0.0 */ - protected final Property httpProxyPort; + private final Property httpProxyPort; /** * Username to authenticate on the proxy server for HTTP requests. * * @since 5.0.0 */ - protected final Property httpProxyUsername; + private final Property httpProxyUsername; /** * Password to authenticate on the proxy server for HTTP requests. * * @since 5.0.0 */ - protected final Property httpProxyPassword; + private final Property httpProxyPassword; /** * Proxy host used to download resources with HTTPS protocol. * * @since 2.1.0 */ - protected final Property httpsProxyHost; + private final Property httpsProxyHost; /** * Proxy port used to download resources with HTTPS protocol. * * @since 2.1.0 */ - protected final Property httpsProxyPort; + private final Property httpsProxyPort; /** * Username to authenticate on the proxy server for HTTPS requests. * * @since 3.0.0 */ - protected final Property httpsProxyUsername; + private final Property httpsProxyUsername; /** * Password to authenticate on the proxy server for HTTPS requests. * * @since 3.0.0 */ - protected final Property httpsProxyPassword; + private final Property httpsProxyPassword; + + /** + * Maximum number of attempts to download a file. + * + * @since 7.1.0 + */ + private final Property maxDownloadAttempts; + + /** + * HTTP statuses that should trigger another download attempt. + * + * @since 7.1.0 + */ + private final SetProperty retryHttpStatuses; + + /** + * Interval between the first download attempt and an eventual retry. + * + * @since 7.1.0 + */ + private final Property retryInitialIntervalMs; + + /** + * Multiplier used to compute the intervals between retry attempts. + * + * @since 7.1.0 + */ + private final Property retryIntervalMultiplier; + + /** + * Maximum interval between retry attempts. + * + * @since 7.1.0 + */ + private final Property retryMaxIntervalMs; @Inject public InstallNodeTask(final ProjectLayout projectLayout, final ObjectFactory objectFactory) { @@ -162,6 +199,11 @@ public InstallNodeTask(final ProjectLayout projectLayout, final ObjectFactory ob this.httpsProxyPort = objectFactory.property(Integer.class); this.httpsProxyUsername = objectFactory.property(String.class); this.httpsProxyPassword = objectFactory.property(String.class); + this.maxDownloadAttempts = objectFactory.property(Integer.class); + this.retryHttpStatuses = objectFactory.setProperty(Integer.class); + this.retryInitialIntervalMs = objectFactory.property(Integer.class); + this.retryIntervalMultiplier = objectFactory.property(Double.class); + this.retryMaxIntervalMs = objectFactory.property(Integer.class); } @Input @@ -234,6 +276,31 @@ public Property getHttpsProxyPassword() { return httpsProxyPassword; } + @Internal + public Property getMaxDownloadAttempts() { + return maxDownloadAttempts; + } + + @Internal + public SetProperty getRetryHttpStatuses() { + return retryHttpStatuses; + } + + @Internal + public Property getRetryInitialIntervalMs() { + return retryInitialIntervalMs; + } + + @Internal + public Property getRetryIntervalMultiplier() { + return retryIntervalMultiplier; + } + + @Internal + public Property getRetryMaxIntervalMs() { + return retryMaxIntervalMs; + } + @OutputFile public RegularFileProperty getNodeExecutableFile() { return nodeExecutableFile; @@ -292,6 +359,14 @@ public void execute() throws BeanRegistryException, FrontendException, IOExcepti .distributionUrlPathPattern(nodeDistributionUrlPathPattern.get()) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(RetrySettings + .builder() + .maxDownloadAttempts(maxDownloadAttempts.get()) + .retryHttpStatuses(retryHttpStatuses.get()) + .retryInitialIntervalMs(retryInitialIntervalMs.get()) + .retryIntervalMultiplier(retryIntervalMultiplier.get()) + .retryMaxIntervalMs(retryMaxIntervalMs.get()) + .build()) .temporaryDirectoryPath(getTemporaryDir().toPath()) .installDirectoryPath(nodeInstallDirectory.map(File::toPath).get()) .build()); diff --git a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/httpclient/ApacheHttpClient.java b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/httpclient/ApacheHttpClient.java index 0d5e2c9e..8b728477 100644 --- a/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/httpclient/ApacheHttpClient.java +++ b/plugin/src/main/java/org/siouan/frontendgradleplugin/infrastructure/httpclient/ApacheHttpClient.java @@ -1,6 +1,7 @@ package org.siouan.frontendgradleplugin.infrastructure.httpclient; import java.io.IOException; +import java.net.Proxy; import java.net.URL; import org.apache.hc.client5.http.ContextBuilder; @@ -38,7 +39,7 @@ protected HttpResponse getRemoteResource(final URL resourceUrl, final Credential // Proxy management final HttpHost proxyServerHost; - if (proxySettings == null) { + if (proxySettings.getProxyType() == Proxy.Type.DIRECT) { proxyServerHost = null; } else { proxyServerHost = new HttpHost(proxySettings.getProxyHost(), proxySettings.getProxyPort()); diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/FrontendGradlePluginTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/FrontendGradlePluginTest.java index 1c017872..9ce76c94 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/FrontendGradlePluginTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/FrontendGradlePluginTest.java @@ -5,6 +5,7 @@ import java.io.File; import java.nio.file.Paths; import java.util.Objects; +import java.util.Set; import org.gradle.api.Project; import org.gradle.api.plugins.BasePlugin; @@ -71,6 +72,16 @@ void should_register_tasks_with_default_extension_values_applied() { assertThat(extension.getHttpsProxyPort().get()).isEqualTo(FrontendGradlePlugin.DEFAULT_HTTPS_PROXY_PORT); assertThat(extension.getHttpsProxyUsername().isPresent()).isFalse(); assertThat(extension.getHttpsProxyPassword().isPresent()).isFalse(); + assertThat(extension.getMaxDownloadAttempts().get()).isEqualTo( + FrontendGradlePlugin.DEFAULT_MAX_DOWNLOAD_ATTEMPTS); + assertThat(extension.getRetryHttpStatuses().get()).containsExactlyInAnyOrderElementsOf( + FrontendGradlePlugin.DEFAULT_RETRY_HTTP_STATUSES); + assertThat(extension.getRetryInitialIntervalMs().get()).isEqualTo( + FrontendGradlePlugin.DEFAULT_RETRY_INITIAL_INTERVAL_MS); + assertThat(extension.getRetryIntervalMultiplier().get()).isEqualTo( + FrontendGradlePlugin.DEFAULT_RETRY_INTERVAL_MULTIPLIER); + assertThat(extension.getRetryMaxIntervalMs().get()).isEqualTo( + FrontendGradlePlugin.DEFAULT_RETRY_MAX_INTERVAL_MS); assertThat(extension.getInternalPackageJsonFile().getAsFile().get()).isEqualTo( project.getProjectDir().toPath().resolve(FrontendGradlePlugin.PACKAGE_JSON_FILE_NAME).toFile()); assertThat(extension.getInternalPackageManagerSpecificationFile().getAsFile().get()).isEqualTo(project @@ -117,6 +128,11 @@ void should_register_tasks_with_custom_extension_values_applied() { extension.getHttpProxyPort().set(8080); extension.getHttpProxyUsername().set("htrshPDA2v6ESar"); extension.getHttpProxyPassword().set("hts`{(gK65geR5=a"); + extension.getMaxDownloadAttempts().set(2); + extension.getRetryHttpStatuses().set(Set.of(404, 503)); + extension.getRetryInitialIntervalMs().set(539); + extension.getRetryIntervalMultiplier().set(7.3); + extension.getRetryMaxIntervalMs().set(9623); extension.getVerboseModeEnabled().set(true); extension.getInternalPackageJsonFile().set(new File("metadata.json")); @@ -152,6 +168,16 @@ private void assertThatTasksAreConfigured(final Project project, final FrontendE extension.getHttpsProxyUsername().getOrNull()); assertThat(installNodeTask.getHttpsProxyPassword().getOrNull()).isEqualTo( extension.getHttpsProxyPassword().getOrNull()); + assertThat(installNodeTask.getMaxDownloadAttempts().getOrNull()).isEqualTo( + extension.getMaxDownloadAttempts().getOrNull()); + assertThat(installNodeTask.getRetryHttpStatuses().getOrNull()).isEqualTo( + extension.getRetryHttpStatuses().getOrNull()); + assertThat(installNodeTask.getRetryInitialIntervalMs().getOrNull()).isEqualTo( + extension.getRetryInitialIntervalMs().getOrNull()); + assertThat(installNodeTask.getRetryIntervalMultiplier().getOrNull()).isEqualTo( + extension.getRetryIntervalMultiplier().getOrNull()); + assertThat(installNodeTask.getRetryMaxIntervalMs().getOrNull()).isEqualTo( + extension.getRetryMaxIntervalMs().getOrNull()); assertThat(installNodeTask.getDependsOn()).isEmpty(); final ResolvePackageManagerTask resolvePackageManagerTask = project diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistributionTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistributionTest.java index 2b92eee7..56bcc361 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistributionTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DeployDistributionTest.java @@ -141,7 +141,6 @@ void should_install_distribution_without_root_directory() usecase.execute(deployDistributionCommand); verify(fileManager).moveFileTree(extractDirectoryPath, installDirectoryPath); - verify(fileManager).deleteIfExists(extractDirectoryPath); verifyNoMoreInteractions(fileManager, archiverProvider, archiver); } @@ -170,7 +169,6 @@ void should_install_distribution_and_remove_root_directory() final ArgumentCaptor sourcePathArgumentCaptor = ArgumentCaptor.forClass(Path.class); verify(fileManager).moveFileTree(sourcePathArgumentCaptor.capture(), eq(installDirectoryPath)); assertThat(sourcePathArgumentCaptor.getValue()).hasParentRaw(extractDirectoryPath); - verify(fileManager).deleteIfExists(extractDirectoryPath); verifyNoMoreInteractions(fileManager, archiverProvider, archiver); } diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommandFixture.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommandFixture.java new file mode 100644 index 00000000..928db9e0 --- /dev/null +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceCommandFixture.java @@ -0,0 +1,28 @@ +package org.siouan.frontendgradleplugin.domain.installer; + +import java.net.MalformedURLException; +import java.nio.file.Path; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DownloadResourceCommandFixture { + + public static DownloadResourceCommand aCommand(final Path resourceFilePath, final ProxySettings proxySettings, + final RetrySettings retrySettings, final Path temporaryFilePath, final Path destinationFilePath) { + try { + return DownloadResourceCommand + .builder() + .resourceUrl(resourceFilePath.toUri().toURL()) + .serverCredentials(null) + .proxySettings(proxySettings) + .retrySettings(retrySettings) + .temporaryFilePath(temporaryFilePath) + .destinationFilePath(destinationFilePath) + .build(); + } catch (final MalformedURLException e) { + throw new RuntimeException(e); + } + } +} diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceTest.java index 57523264..5fff3aaf 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/DownloadResourceTest.java @@ -1,27 +1,26 @@ package org.siouan.frontendgradleplugin.domain.installer; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.io.IOException; import java.io.InputStream; -import java.net.MalformedURLException; -import java.net.Proxy; import java.nio.channels.FileChannel; import java.nio.channels.ReadableByteChannel; import java.nio.file.Path; -import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.exceptions.verification.NoInteractionsWanted; @@ -39,12 +38,11 @@ @ExtendWith(MockitoExtension.class) class DownloadResourceTest { - private static final String DOWNLOAD_DIRECTORY_NAME = "download"; + private static final Path DESTINATION_FILE_PATH = Path.of("/dir1/dest"); - private static final String RESOURCE_NAME = "resource.zip"; + private static final Path RESOURCE_FILE_PATH = Path.of("/dir2/resource"); - @TempDir - Path temporaryDirectoryPath; + private static final Path TEMPORARY_FILE_PATH = Path.of("/dir3/tmp"); @Mock private HttpResponse httpResponse; @@ -74,61 +72,69 @@ void setUp() { } @Test - void should_fail_when_http_request_fails() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters(Paths.get("/y45y97@p")); - final IOException expectedException = new IOException(); + void should_retry_and_fail_when_http_server_is_not_reachable() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommandWithOneRetry(); + final IOException exception1 = new IOException(); + final IOException exception2 = new IOException(); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenThrow( - expectedException); + exception1, exception2); - assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOfSatisfying( + RetryableResourceDownloadException.class, + retryableResourceDownloadException -> assertThat(retryableResourceDownloadException).hasCause(exception2)); - verify(fileManager).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); + verify(fileManager, times(2)).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_fail_when_resource_cannot_be_downloaded() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters(Paths.get("/,èjtt(é")); + void should_retry_and_fail_when_http_response_cannot_be_read() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommandWithOneRetry(); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); - final IOException expectedException = new IOException(); - when(httpResponse.getInputStream()).thenThrow(expectedException); + final IOException exception1 = new IOException(); + final IOException exception2 = new IOException(); + when(httpResponse.getInputStream()).thenThrow(exception1, exception2); - assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOfSatisfying( + RetryableResourceDownloadException.class, + retryableResourceDownloadException -> assertThat(retryableResourceDownloadException).hasCause(exception2)); - verify(httpResponse).close(); - verify(fileManager).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); + verify(httpResponse, times(2)).close(); + verify(fileManager, times(2)).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_fail_when_temporary_file_cannot_be_created() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters( - Paths.get("/volezp", "gixkkle")); + void should_retry_and_fail_when_temporary_file_cannot_be_created_to_write_resource() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommandWithOneRetry(); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); when(httpResponse.getInputStream()).thenReturn(inputStream); final ReadableByteChannel resourceInputChannel = mock(ReadableByteChannel.class); when(channelProvider.getReadableByteChannel(inputStream)).thenReturn(resourceInputChannel); - final IOException expectedException = new IOException(); + final IOException exception1 = new IOException(); + final IOException exception2 = new IOException(); when(channelProvider.getWritableFileChannelForNewFile(downloadResourceCommand.getTemporaryFilePath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)).thenThrow( - expectedException); + exception1, exception2); - assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOfSatisfying( + RetryableResourceDownloadException.class, + retryableResourceDownloadException -> assertThat(retryableResourceDownloadException).hasCause(exception2)); - verify(resourceInputChannel).close(); - verify(httpResponse).close(); - verify(fileManager).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); + verify(resourceInputChannel, times(2)).close(); + verify(httpResponse, times(2)).close(); + verify(fileManager, times(2)).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_fail_when_http_response_is_not_ok() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters(Paths.get("/htrsgvrqehjynjt")); + void should_retry_and_fail_when_http_response_status_code_is_not_ok_and_retryable() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommand(2, Set.of(404)); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); @@ -144,16 +150,14 @@ void should_fail_when_http_response_is_not_ok() throws IOException { assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOf( ResourceDownloadException.class); - verify(resourceInputChannel).close(); - verify(httpResponse).close(); - verify(fileManager).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); + verify(resourceInputChannel, times(2)).close(); + verify(httpResponse, times(2)).close(); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_fail_when_data_transfer_tails() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters( - Paths.get("/volezp", "gixkkle")); + void should_not_retry_and_fail_when_http_response_status_code_is_not_ok_and_not_retryable() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommand(2, Set.of(502)); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); @@ -164,21 +168,19 @@ void should_fail_when_data_transfer_tails() throws IOException { when(channelProvider.getWritableFileChannelForNewFile(downloadResourceCommand.getTemporaryFilePath(), StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)).thenReturn( resourceOutputChannel); - when(httpResponse.getStatusCode()).thenReturn(200); - final IOException expectedException = new IOException(); - when(resourceOutputChannel.transferFrom(resourceInputChannel, 0, Long.MAX_VALUE)).thenThrow(expectedException); + when(httpResponse.getStatusCode()).thenReturn(404); - assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOf( + ResourceDownloadException.class); verify(resourceInputChannel).close(); verify(httpResponse).close(); - verify(fileManager).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_fail_when_temporary_file_cannot_be_moved_to_destination_file() throws IOException { - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters(Paths.get("/volhrts '4")); + void should_fail_without_retry_when_resource_writing_fails() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommandWithOneRetry(); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); @@ -190,31 +192,24 @@ void should_fail_when_temporary_file_cannot_be_moved_to_destination_file() throw StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)).thenReturn( resourceOutputChannel); when(httpResponse.getStatusCode()).thenReturn(200); - final Exception expectedException = new IOException(); - when(fileManager.move(downloadResourceCommand.getTemporaryFilePath(), - downloadResourceCommand.getDestinationFilePath(), StandardCopyOption.REPLACE_EXISTING)).thenThrow( - expectedException); - - assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); - - verify(resourceOutputChannel).transferFrom(resourceInputChannel, 0, Long.MAX_VALUE); - verify(resourceInputChannel).close(); - verify(httpResponse).close(); + final IOException exception1 = new IOException(); + final IOException exception2 = new IOException(); + when(resourceOutputChannel.transferFrom(resourceInputChannel, 0, Long.MAX_VALUE)).thenThrow(exception1, + exception2); + + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isInstanceOfSatisfying( + RetryableResourceDownloadException.class, + retryableResourceDownloadException -> assertThat(retryableResourceDownloadException).hasCause(exception2)); + + verify(resourceInputChannel, times(2)).close(); + verify(httpResponse, times(2)).close(); + verify(fileManager, times(2)).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } @Test - void should_download_resource() throws IOException, ResourceDownloadException { - final Path destinationDirectoryPath = temporaryDirectoryPath.resolve("install"); - final Path destinationFilePath = destinationDirectoryPath.resolve(RESOURCE_NAME); - final DownloadResourceCommand downloadResourceCommand = buildDownloadParameters(destinationFilePath, - ProxySettings - .builder() - .proxyType(Proxy.Type.HTTP) - .proxyHost("localhost") - .proxyPort(8080) - .credentials(null) - .build()); + void should_not_retry_but_fail_when_temporary_file_cannot_be_moved_to_destination_file() throws IOException { + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommandWithOneRetry(); when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())).thenReturn( httpResponse); @@ -226,33 +221,63 @@ void should_download_resource() throws IOException, ResourceDownloadException { StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)).thenReturn( resourceOutputChannel); when(httpResponse.getStatusCode()).thenReturn(200); + final Exception expectedException = new IOException(); + when(fileManager.move(downloadResourceCommand.getTemporaryFilePath(), + downloadResourceCommand.getDestinationFilePath(), StandardCopyOption.REPLACE_EXISTING)).thenThrow( + expectedException); - usecase.execute(downloadResourceCommand); + assertThatThrownBy(() -> usecase.execute(downloadResourceCommand)).isEqualTo(expectedException); verify(resourceOutputChannel).transferFrom(resourceInputChannel, 0, Long.MAX_VALUE); verify(resourceInputChannel).close(); verify(httpResponse).close(); - verify(fileManager).move(downloadResourceCommand.getTemporaryFilePath(), - downloadResourceCommand.getDestinationFilePath(), StandardCopyOption.REPLACE_EXISTING); verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } - private Path getTemporaryFilePath() { - return temporaryDirectoryPath.resolve(DOWNLOAD_DIRECTORY_NAME); - } + @Test + void should_download_resource_after_retries() throws IOException, ResourceDownloadException { + final int retryableHttpStatus = 502; + final DownloadResourceCommand downloadResourceCommand = aDownloadResourceCommand(10, + Set.of(retryableHttpStatus)); + final IOException exception = new IOException(); + when(httpClient.sendGetRequest(downloadResourceCommand.getResourceUrl(), + downloadResourceCommand.getServerCredentials(), downloadResourceCommand.getProxySettings())) + .thenThrow(exception) + .thenReturn(httpResponse); + when(httpResponse.getInputStream()).thenThrow(new IOException()).thenReturn(inputStream); + final ReadableByteChannel resourceInputChannel = mock(ReadableByteChannel.class); + when(channelProvider.getReadableByteChannel(inputStream)).thenReturn(resourceInputChannel); + final FileChannel resourceOutputChannel = spy(FileChannel.class); + when(channelProvider.getWritableFileChannelForNewFile(downloadResourceCommand.getTemporaryFilePath(), + StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) + .thenThrow(exception) + .thenReturn(resourceOutputChannel); + when(httpResponse.getStatusCode()).thenReturn(retryableHttpStatus, 200); + when(resourceOutputChannel.transferFrom(resourceInputChannel, 0, Long.MAX_VALUE)) + .thenThrow(exception) + .thenReturn(1L); + when(fileManager.deleteIfExists(downloadResourceCommand.getTemporaryFilePath())).thenThrow(exception); + when(fileManager.move(downloadResourceCommand.getTemporaryFilePath(), + downloadResourceCommand.getDestinationFilePath(), StandardCopyOption.REPLACE_EXISTING)).thenReturn( + downloadResourceCommand.getDestinationFilePath()); - private Path getResourceFilePath() { - return temporaryDirectoryPath.resolve(RESOURCE_NAME); + usecase.execute(downloadResourceCommand); + + verify(resourceInputChannel, times(4)).close(); + verify(httpResponse, times(5)).close(); + verify(fileManager, times(4)).deleteIfExists(downloadResourceCommand.getTemporaryFilePath()); + verifyNoMoreInteractions(fileManager, channelProvider, httpClientProvider, httpClient); } - private DownloadResourceCommand buildDownloadParameters(final Path destinationFilePath) - throws MalformedURLException { - return buildDownloadParameters(destinationFilePath, null); + private DownloadResourceCommand aDownloadResourceCommandWithOneRetry() { + return aDownloadResourceCommand(2, Set.of()); } - private DownloadResourceCommand buildDownloadParameters(final Path destinationFilePath, - final ProxySettings proxySettings) throws MalformedURLException { - return new DownloadResourceCommand(getResourceFilePath().toUri().toURL(), null, proxySettings, - getTemporaryFilePath(), destinationFilePath); + private DownloadResourceCommand aDownloadResourceCommand(final int maxDownloadAttempts, + final Set retryableHttpStatuses) { + return DownloadResourceCommandFixture.aCommand(RESOURCE_FILE_PATH, + (maxDownloadAttempts == 2) ? ProxySettingsFixture.direct() : ProxySettingsFixture.someProxySettings(), + RetrySettingsFixture.someRetrySettings(maxDownloadAttempts, retryableHttpStatuses), TEMPORARY_FILE_PATH, + DESTINATION_FILE_PATH); } } diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionTest.java index 53e77839..011dfa2a 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/GetDistributionTest.java @@ -80,7 +80,7 @@ void should_fail_getting_distribution_when_current_platform_is_not_supported_by_ .build())).thenThrow(expectedException); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, CredentialsFixture.someCredentials(), - ProxySettingsFixture.someProxySettings(), temporaryDirectoryPath); + ProxySettingsFixture.someProxySettings(), RetrySettingsFixture.someRetrySettings(), temporaryDirectoryPath); assertThatThrownBy(() -> usecase.execute(getDistributionCommand)).isEqualTo(expectedException); @@ -105,7 +105,7 @@ void should_fail_getting_distribution_when_distribution_url_is_malformed() .build())).thenThrow(expectedException); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, CredentialsFixture.someCredentials(), - ProxySettingsFixture.someProxySettings(), temporaryDirectoryPath); + ProxySettingsFixture.someProxySettings(), RetrySettingsFixture.someRetrySettings(), temporaryDirectoryPath); assertThatThrownBy(() -> usecase.execute(getDistributionCommand)).isEqualTo(expectedException); @@ -129,7 +129,7 @@ void should_fail_getting_distribution_when_resolved_url_is_invalid() .build())).thenReturn(new URL("https://domain.com/")); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, CredentialsFixture.someCredentials(), - ProxySettingsFixture.someProxySettings(), temporaryDirectoryPath); + ProxySettingsFixture.someProxySettings(), RetrySettingsFixture.someRetrySettings(), temporaryDirectoryPath); assertThatThrownBy(() -> usecase.execute(getDistributionCommand)).isInstanceOf( InvalidDistributionUrlException.class); @@ -147,6 +147,7 @@ void should_fail_getting_distribution_when_download_fails() final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final URL downloadUrl = new URL(distributionUrlRoot + distributionUrlPathPattern); when(resolveNodeDistributionUrl.execute(ResolveNodeDistributionUrlCommand .builder() @@ -166,11 +167,12 @@ void should_fail_getting_distribution_when_download_fails() .temporaryFilePath(temporaryDirectoryPath.resolve(TMP_DISTRIBUTION_NAME)) .destinationFilePath(distributionFilePath) .proxySettings(proxySettings) + .retrySettings(retrySettings) .serverCredentials(distributionServerCredentials) .build()); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, proxySettings, - temporaryDirectoryPath); + retrySettings, temporaryDirectoryPath); assertThatThrownBy(() -> usecase.execute(getDistributionCommand)).isEqualTo(expectedException); @@ -187,6 +189,7 @@ void should_fail_getting_distribution_when_distribution_is_invalid_after_downloa final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final URL downloadUrl = new URL(distributionUrlRoot + distributionUrlPathPattern); when(resolveNodeDistributionUrl.execute(ResolveNodeDistributionUrlCommand .builder() @@ -206,11 +209,12 @@ void should_fail_getting_distribution_when_distribution_is_invalid_after_downloa .distributionUrl(downloadUrl) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build()); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, proxySettings, - temporaryDirectoryPath); + retrySettings, temporaryDirectoryPath); assertThatThrownBy(() -> usecase.execute(getDistributionCommand)).isEqualTo(expectedException); @@ -220,6 +224,7 @@ void should_fail_getting_distribution_when_distribution_is_invalid_after_downloa .temporaryFilePath(temporaryDirectoryPath.resolve(TMP_DISTRIBUTION_NAME)) .destinationFilePath(distributionFilePath) .proxySettings(proxySettings) + .retrySettings(retrySettings) .serverCredentials(distributionServerCredentials) .build()); verifyNoMoreInteractions(resolveNodeDistributionUrl, buildTemporaryFileName, downloadResource, @@ -234,6 +239,7 @@ void should_get_valid_distribution_when_validator_is_available() throws Frontend final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final URL downloadUrl = new URL(distributionUrlRoot + distributionUrlPathPattern); when(resolveNodeDistributionUrl.execute(ResolveNodeDistributionUrlCommand .builder() @@ -245,7 +251,7 @@ void should_get_valid_distribution_when_validator_is_available() throws Frontend when(buildTemporaryFileName.execute(DISTRIBUTION_NAME)).thenReturn(TMP_DISTRIBUTION_NAME); final GetDistributionCommand getDistributionCommand = new GetDistributionCommand(platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, proxySettings, - temporaryDirectoryPath); + retrySettings, temporaryDirectoryPath); final Path distributionFilePath = temporaryDirectoryPath.resolve(DISTRIBUTION_NAME); assertThat(usecase.execute(getDistributionCommand)).isEqualTo(distributionFilePath); @@ -256,6 +262,7 @@ void should_get_valid_distribution_when_validator_is_available() throws Frontend .temporaryFilePath(temporaryDirectoryPath.resolve(TMP_DISTRIBUTION_NAME)) .destinationFilePath(distributionFilePath) .proxySettings(proxySettings) + .retrySettings(retrySettings) .serverCredentials(distributionServerCredentials) .build()); verify(validateNodeDistribution).execute(ValidateNodeDistributionCommand @@ -264,6 +271,7 @@ void should_get_valid_distribution_when_validator_is_available() throws Frontend .distributionUrl(downloadUrl) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build()); verifyNoMoreInteractions(resolveNodeDistributionUrl, buildTemporaryFileName, downloadResource, diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionTest.java index 9dfe921b..29504311 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/InstallNodeDistributionTest.java @@ -1,6 +1,7 @@ package org.siouan.frontendgradleplugin.domain.installer; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -63,8 +64,8 @@ void should_fail_when_install_directory_cannot_be_deleted() throws IOException { doThrow(expectedException).when(fileManager).deleteFileTree(installDirectoryPath, true); final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( LOCAL_PLATFORM, VERSION, DISTRIBUTION_URL_ROOT, DISTRIBUTION_URL_PATH_PATTERN, - CredentialsFixture.someCredentials(), ProxySettingsFixture.someProxySettings(), temporaryDirectoryPath, - installDirectoryPath); + CredentialsFixture.someCredentials(), ProxySettingsFixture.someProxySettings(), + RetrySettingsFixture.someRetrySettings(), temporaryDirectoryPath, installDirectoryPath); assertThatThrownBy(() -> usecase.execute(installNodeDistributionCommand)).isEqualTo(expectedException); @@ -79,6 +80,7 @@ void should_fail_when_distribution_cannot_be_retrieved() throws IOException, Fro final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final Exception expectedException = new UnsupportedPlatformException(LOCAL_PLATFORM); when(getDistribution.execute(GetDistributionCommand .builder() @@ -88,11 +90,12 @@ void should_fail_when_distribution_cannot_be_retrieved() throws IOException, Fro .distributionUrlPathPattern(distributionUrlPathPattern) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build())).thenThrow(expectedException); final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, - proxySettings, temporaryDirectoryPath, installDirectoryPath); + proxySettings, retrySettings, temporaryDirectoryPath, installDirectoryPath); assertThatThrownBy(() -> usecase.execute(installNodeDistributionCommand)).isEqualTo(expectedException); @@ -100,6 +103,39 @@ void should_fail_when_distribution_cannot_be_retrieved() throws IOException, Fro verifyNoMoreInteractions(fileManager, getDistribution, deployDistribution); } + @Test + void should_fail_when_temporary_extract_directory_cannot_be_deleted() throws IOException, FrontendException { + final Platform platform = LOCAL_PLATFORM; + final String version = VERSION; + final String distributionUrlRoot = DISTRIBUTION_URL_ROOT; + final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; + final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); + final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); + final Path distributionFilePath = temporaryDirectoryPath.resolve("dist.zip"); + when(getDistribution.execute(GetDistributionCommand + .builder() + .platform(platform) + .version(version) + .distributionUrlRoot(distributionUrlRoot) + .distributionUrlPathPattern(distributionUrlPathPattern) + .distributionServerCredentials(distributionServerCredentials) + .proxySettings(proxySettings) + .retrySettings(retrySettings) + .temporaryDirectoryPath(temporaryDirectoryPath) + .build())).thenReturn(distributionFilePath); + doNothing().when(fileManager).deleteFileTree(installDirectoryPath, true); + final Exception expectedException = new IOException(); + doThrow(expectedException).when(fileManager).deleteFileTree(extractDirectoryPath, true); + final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( + platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, + proxySettings, retrySettings, temporaryDirectoryPath, installDirectoryPath); + + assertThatThrownBy(() -> usecase.execute(installNodeDistributionCommand)).isEqualTo(expectedException); + + verifyNoMoreInteractions(fileManager, getDistribution, deployDistribution); + } + @Test void should_fail_when_distribution_cannot_be_deployed() throws IOException, FrontendException { final Platform platform = LOCAL_PLATFORM; @@ -108,6 +144,7 @@ void should_fail_when_distribution_cannot_be_deployed() throws IOException, Fron final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final Path distributionFilePath = temporaryDirectoryPath.resolve("dist.zip"); when(getDistribution.execute(GetDistributionCommand .builder() @@ -117,6 +154,7 @@ void should_fail_when_distribution_cannot_be_deployed() throws IOException, Fron .distributionUrlPathPattern(distributionUrlPathPattern) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build())).thenReturn(distributionFilePath); final Exception expectedException = mock(UnsupportedDistributionArchiveException.class); @@ -131,11 +169,12 @@ void should_fail_when_distribution_cannot_be_deployed() throws IOException, Fron .build()); final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, - proxySettings, temporaryDirectoryPath, installDirectoryPath); + proxySettings, retrySettings, temporaryDirectoryPath, installDirectoryPath); assertThatThrownBy(() -> usecase.execute(installNodeDistributionCommand)).isEqualTo(expectedException); verify(fileManager).deleteFileTree(installDirectoryPath, true); + verify(fileManager).deleteFileTree(extractDirectoryPath, true); verifyNoMoreInteractions(fileManager, getDistribution, deployDistribution); } @@ -147,6 +186,7 @@ void should_fail_when_downloaded_distribution_file_cannot_be_deleted() throws IO final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final Path distributionFilePath = temporaryDirectoryPath.resolve("dist.zip"); when(getDistribution.execute(GetDistributionCommand .builder() @@ -156,17 +196,19 @@ void should_fail_when_downloaded_distribution_file_cannot_be_deleted() throws IO .distributionUrlPathPattern(distributionUrlPathPattern) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build())).thenReturn(distributionFilePath); final Exception expectedException = new IOException(); doThrow(expectedException).when(fileManager).delete(distributionFilePath); final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, - proxySettings, temporaryDirectoryPath, installDirectoryPath); + proxySettings, retrySettings, temporaryDirectoryPath, installDirectoryPath); assertThatThrownBy(() -> usecase.execute(installNodeDistributionCommand)).isEqualTo(expectedException); verify(fileManager).deleteFileTree(installDirectoryPath, true); + verify(fileManager).deleteFileTree(extractDirectoryPath, true); verify(deployDistribution).execute(DeployDistributionCommand .builder() .platform(platform) @@ -185,6 +227,7 @@ void should_install_distribution() throws IOException, FrontendException { final String distributionUrlPathPattern = DISTRIBUTION_URL_PATH_PATTERN; final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final Path distributionFilePath = temporaryDirectoryPath.resolve("dist.zip"); when(getDistribution.execute(GetDistributionCommand .builder() @@ -194,15 +237,17 @@ void should_install_distribution() throws IOException, FrontendException { .distributionUrlPathPattern(distributionUrlPathPattern) .distributionServerCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .temporaryDirectoryPath(temporaryDirectoryPath) .build())).thenReturn(distributionFilePath); final InstallNodeDistributionCommand installNodeDistributionCommand = new InstallNodeDistributionCommand( platform, version, distributionUrlRoot, distributionUrlPathPattern, distributionServerCredentials, - proxySettings, temporaryDirectoryPath, installDirectoryPath); + proxySettings, retrySettings, temporaryDirectoryPath, installDirectoryPath); usecase.execute(installNodeDistributionCommand); verify(fileManager).deleteFileTree(installDirectoryPath, true); + verify(fileManager).deleteFileTree(extractDirectoryPath, true); verify(deployDistribution).execute(DeployDistributionCommand .builder() .platform(platform) diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettingsFixture.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettingsFixture.java index 49b64260..5ef29ed4 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettingsFixture.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ProxySettingsFixture.java @@ -8,6 +8,10 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ProxySettingsFixture { + public static ProxySettings direct() { + return ProxySettings.builder().proxyType(Proxy.Type.DIRECT).build(); + } + public static ProxySettings someProxySettings() { return ProxySettings .builder() diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrlTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrlTest.java index b5c525ee..fce773cc 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrlTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ResolveProxySettingsByUrlTest.java @@ -7,6 +7,7 @@ import static org.siouan.frontendgradleplugin.domain.installer.ProxySettingsFixture.someProxySettings; import java.net.MalformedURLException; +import java.net.Proxy; import java.net.URL; import java.util.Set; @@ -67,19 +68,21 @@ void should_fail_when_url_uses_unsupported_protocol() throws MalformedURLExcepti } @Test - void should_return_null_when_url_uses_file_protocol() throws MalformedURLException { - assertThat(usecase.execute(ResolveProxySettingsByUrlCommand - .builder() - .httpProxyPort(80) - .httpsProxyPort(443) - .resourceUrl(new URL(FILE_RESOURCE_URL)) - .build())).isNull(); + void should_return_direct_connection_when_url_uses_file_protocol() throws MalformedURLException { + assertThat(usecase + .execute(ResolveProxySettingsByUrlCommand + .builder() + .httpProxyPort(80) + .httpsProxyPort(443) + .resourceUrl(new URL(FILE_RESOURCE_URL)) + .build()) + .getProxyType()).isEqualTo(Proxy.Type.DIRECT); verifyNoMoreInteractions(systemSettingsProvider, isNonProxyHost, selectProxySettings); } @Test - void should_return_null_when_url_uses_non_proxy_host() throws MalformedURLException { + void should_return_direct_connection_when_url_uses_non_proxy_host() throws MalformedURLException { final Set nonProxyHosts = Set.of(PLUGIN_PROXY_HOST); when(systemSettingsProvider.getNonProxyHosts()).thenReturn(nonProxyHosts); final URL resourceUrl = new URL(HTTP_RESOURCE_URL); @@ -89,12 +92,14 @@ void should_return_null_when_url_uses_non_proxy_host() throws MalformedURLExcept .hostNameOrIpAddress(resourceUrl.getHost()) .build())).thenReturn(true); - assertThat(usecase.execute(ResolveProxySettingsByUrlCommand - .builder() - .httpProxyPort(80) - .httpsProxyPort(443) - .resourceUrl(resourceUrl) - .build())).isNull(); + assertThat(usecase + .execute(ResolveProxySettingsByUrlCommand + .builder() + .httpProxyPort(80) + .httpsProxyPort(443) + .resourceUrl(resourceUrl) + .build()) + .getProxyType()).isEqualTo(Proxy.Type.DIRECT); verifyNoMoreInteractions(systemSettingsProvider, isNonProxyHost, selectProxySettings); } diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettingsFixture.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettingsFixture.java new file mode 100644 index 00000000..043059f7 --- /dev/null +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/RetrySettingsFixture.java @@ -0,0 +1,29 @@ +package org.siouan.frontendgradleplugin.domain.installer; + +import java.util.Set; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RetrySettingsFixture { + + public static RetrySettings someRetrySettings() { + return someRetrySettings(1); + } + + public static RetrySettings someRetrySettings(final int maxDownloadAttempts) { + return someRetrySettings(maxDownloadAttempts, Set.of(502, 503, 504)); + } + + public static RetrySettings someRetrySettings(final int maxDownloadAttempts, final Set retryHttpStatuses) { + return RetrySettings + .builder() + .maxDownloadAttempts(maxDownloadAttempts) + .retryHttpStatuses(retryHttpStatuses) + .retryInitialIntervalMs(100) + .retryIntervalMultiplier(2) + .retryMaxIntervalMs(500) + .build(); + } +} diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettingsTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettingsTest.java index 0c0685fe..a4444bcd 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettingsTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/SelectProxySettingsTest.java @@ -25,8 +25,9 @@ class SelectProxySettingsTest { private SelectProxySettings usecase; @Test - void should_return_null_if_system_proxy_host_and_plugin_proxy_host_are_null() { - assertThat(usecase.execute(SelectProxySettingsCommand.builder().build())).isNull(); + void should_return_direct_connection_if_system_proxy_host_and_plugin_proxy_host_are_null() { + assertThat(usecase.execute(SelectProxySettingsCommand.builder().build()).getProxyType()).isEqualTo( + Proxy.Type.DIRECT); } @Test diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionTest.java index 438ff93f..d69b97a4 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/domain/installer/ValidateNodeDistributionTest.java @@ -82,6 +82,7 @@ void should_fail_when_shasums_cannot_be_downloaded() throws IOException, Resourc when(buildTemporaryFileName.execute(SHASUMS_FILE_NAME)).thenReturn(TMP_SHASUMS_FILE_NAME); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final Exception expectedException = new IOException(); doThrow(expectedException) .when(downloadResource) @@ -92,9 +93,10 @@ void should_fail_when_shasums_cannot_be_downloaded() throws IOException, Resourc .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); assertThatThrownBy(() -> usecase.execute(validateNodeDistributionCommand)).isEqualTo(expectedException); @@ -115,8 +117,9 @@ void should_fail_when_shasums_cannot_be_read() throws IOException, ResourceDownl .build())).thenThrow(expectedException); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); assertThatThrownBy(() -> usecase.execute(validateNodeDistributionCommand)).isEqualTo(expectedException); @@ -128,6 +131,7 @@ void should_fail_when_shasums_cannot_be_read() throws IOException, ResourceDownl .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); verify(fileManager).deleteIfExists(downloadedShasumFilepath); verifyNoMoreInteractions(fileManager, downloadResource, readNodeDistributionShasum, hashFile); @@ -144,8 +148,9 @@ void should_fail_when_shasum_is_not_found() throws IOException, ResourceDownload .build())).thenReturn(Optional.empty()); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); assertThatThrownBy(() -> usecase.execute(validateNodeDistributionCommand)).isInstanceOf( @@ -158,6 +163,7 @@ void should_fail_when_shasum_is_not_found() throws IOException, ResourceDownload .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); verify(fileManager).deleteIfExists(downloadedShasumFilepath); verifyNoMoreInteractions(fileManager, downloadResource, readNodeDistributionShasum, hashFile); @@ -176,8 +182,9 @@ void should_fail_when_distribution_file_cannot_be_hashed() throws IOException, R when(hashFile.execute(DISTRIBUTION_FILE_PATH)).thenThrow(expectedException); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); assertThatThrownBy(() -> usecase.execute(validateNodeDistributionCommand)).isEqualTo(expectedException); @@ -189,6 +196,7 @@ void should_fail_when_distribution_file_cannot_be_hashed() throws IOException, R .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); verify(fileManager).deleteIfExists(downloadedShasumFilepath); verifyNoMoreInteractions(fileManager, downloadResource, readNodeDistributionShasum, hashFile); @@ -207,8 +215,9 @@ void should_fail_when_distribution_file_hash_is_incorrect() throws IOException, when(hashFile.execute(DISTRIBUTION_FILE_PATH)).thenReturn("fedcba98765543210"); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); assertThatThrownBy(() -> usecase.execute(validateNodeDistributionCommand)).isInstanceOf( @@ -221,6 +230,7 @@ void should_fail_when_distribution_file_hash_is_incorrect() throws IOException, .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); verify(fileManager).deleteIfExists(downloadedShasumFilepath); verifyNoMoreInteractions(fileManager, downloadResource, readNodeDistributionShasum, hashFile); @@ -241,8 +251,9 @@ void should_return_when_distribution_file_is_valid() when(hashFile.execute(DISTRIBUTION_FILE_PATH)).thenReturn(expectedHash); final Credentials distributionServerCredentials = CredentialsFixture.someCredentials(); final ProxySettings proxySettings = ProxySettingsFixture.someProxySettings(); + final RetrySettings retrySettings = RetrySettingsFixture.someRetrySettings(); final ValidateNodeDistributionCommand validateNodeDistributionCommand = new ValidateNodeDistributionCommand( - temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, + temporaryDirectoryPath, DISTRIBUTION_URL, distributionServerCredentials, proxySettings, retrySettings, DISTRIBUTION_FILE_PATH); usecase.execute(validateNodeDistributionCommand); @@ -254,6 +265,7 @@ void should_return_when_distribution_file_is_valid() .destinationFilePath(downloadedShasumFilepath) .serverCredentials(distributionServerCredentials) .proxySettings(proxySettings) + .retrySettings(retrySettings) .build()); verify(fileManager).deleteIfExists(downloadedShasumFilepath); verifyNoMoreInteractions(fileManager, downloadResource, readNodeDistributionShasum, hashFile); diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/TarEntryTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/TarEntryTest.java index c13648ea..41872f10 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/TarEntryTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/TarEntryTest.java @@ -3,9 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import java.util.stream.Stream; + import org.apache.commons.compress.archivers.tar.TarArchiveEntry; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -24,72 +28,28 @@ class TarEntryTest { @Mock private TarArchiveEntry lowLevelEntry; - @Test - void should_map_entry_to_directory_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = true; - final boolean isSymbolicLink = false; - final boolean isFile = false; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isSymbolicLink()).thenReturn(isSymbolicLink); - when(lowLevelEntry.isFile()).thenReturn(isFile); + @ParameterizedTest + @MethodSource("generateArguments") + void should_map_entry_to_directory_archive_entry(final String entryName, final int unixMode, + final boolean directory, final boolean symbolicLink, final boolean regularFile) { + when(lowLevelEntry.getName()).thenReturn(entryName); when(lowLevelEntry.getMode()).thenReturn(unixMode); + when(lowLevelEntry.isDirectory()).thenReturn(directory); + when(lowLevelEntry.isSymbolicLink()).thenReturn(symbolicLink); + when(lowLevelEntry.isFile()).thenReturn(regularFile); final TarEntry entry = new TarEntry(lowLevelEntry); assertThat(entry.lowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isEqualTo(isFile); + assertThat(entry.getName()).isEqualTo(entryName); assertThat(entry.getUnixMode()).isEqualTo(unixMode); + assertThat(entry.isDirectory()).isEqualTo(directory); + assertThat(entry.isSymbolicLink()).isEqualTo(symbolicLink); + assertThat(entry.isFile()).isEqualTo(regularFile); } - @Test - void should_map_entry_to_symbolic_link_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = false; - final boolean isSymbolicLink = true; - final boolean isFile = false; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isSymbolicLink()).thenReturn(isSymbolicLink); - when(lowLevelEntry.isFile()).thenReturn(isFile); - when(lowLevelEntry.getMode()).thenReturn(unixMode); - - final TarEntry entry = new TarEntry(lowLevelEntry); - - assertThat(entry.lowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isEqualTo(isFile); - assertThat(entry.getUnixMode()).isEqualTo(unixMode); - } - - @Test - void should_map_entry_to_file_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = false; - final boolean isSymbolicLink = false; - final boolean isFile = true; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isSymbolicLink()).thenReturn(isSymbolicLink); - when(lowLevelEntry.isFile()).thenReturn(isFile); - when(lowLevelEntry.getMode()).thenReturn(unixMode); - - final TarEntry entry = new TarEntry(lowLevelEntry); - - assertThat(entry.lowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isEqualTo(isFile); - assertThat(entry.getUnixMode()).isEqualTo(unixMode); + private static Stream generateArguments() { + return Stream.of(Arguments.of(NAME, UNIX_MODE, true, false, false), + Arguments.of(NAME, UNIX_MODE, false, true, false), Arguments.of(NAME, UNIX_MODE, false, false, true)); } } diff --git a/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/ZipEntryTest.java b/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/ZipEntryTest.java index 5ab6f3d6..2312d8e9 100644 --- a/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/ZipEntryTest.java +++ b/plugin/src/test/java/org/siouan/frontendgradleplugin/infrastructure/archiver/ZipEntryTest.java @@ -3,9 +3,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import java.util.stream.Stream; + import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -24,66 +28,27 @@ class ZipEntryTest { @Mock private ZipArchiveEntry lowLevelEntry; - @Test - void should_map_entry_to_directory_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = true; - final boolean isSymbolicLink = false; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isUnixSymlink()).thenReturn(isSymbolicLink); + @ParameterizedTest + @MethodSource("generateArguments") + void should_map_entry_to_directory_archive_entry(final String entryName, final int unixMode, + final boolean directory, final boolean symbolicLink, final boolean regularFileExpected) { + when(lowLevelEntry.getName()).thenReturn(entryName); when(lowLevelEntry.getUnixMode()).thenReturn(unixMode); + when(lowLevelEntry.isDirectory()).thenReturn(directory); + when(lowLevelEntry.isUnixSymlink()).thenReturn(symbolicLink); final ZipEntry entry = new ZipEntry(lowLevelEntry); assertThat(entry.getLowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isFalse(); + assertThat(entry.getName()).isEqualTo(entryName); assertThat(entry.getUnixMode()).isEqualTo(unixMode); + assertThat(entry.isDirectory()).isEqualTo(directory); + assertThat(entry.isSymbolicLink()).isEqualTo(symbolicLink); + assertThat(entry.isFile()).isEqualTo(regularFileExpected); } - @Test - void should_map_entry_to_symbolic_link_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = false; - final boolean isSymbolicLink = true; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isUnixSymlink()).thenReturn(isSymbolicLink); - when(lowLevelEntry.getUnixMode()).thenReturn(unixMode); - - final ZipEntry entry = new ZipEntry(lowLevelEntry); - - assertThat(entry.getLowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isFalse(); - assertThat(entry.getUnixMode()).isEqualTo(unixMode); - } - - @Test - void should_map_entry_to_file_archive_entry() { - final String name = NAME; - final int unixMode = UNIX_MODE; - final boolean isDirectory = false; - final boolean isSymbolicLink = false; - when(lowLevelEntry.getName()).thenReturn(name); - when(lowLevelEntry.isDirectory()).thenReturn(isDirectory); - when(lowLevelEntry.isUnixSymlink()).thenReturn(isSymbolicLink); - when(lowLevelEntry.getUnixMode()).thenReturn(unixMode); - - final ZipEntry entry = new ZipEntry(lowLevelEntry); - - assertThat(entry.getLowLevelEntry()).isEqualTo(lowLevelEntry); - assertThat(entry.getName()).isEqualTo(name); - assertThat(entry.isDirectory()).isEqualTo(isDirectory); - assertThat(entry.isSymbolicLink()).isEqualTo(isSymbolicLink); - assertThat(entry.isFile()).isTrue(); - assertThat(entry.getUnixMode()).isEqualTo(unixMode); + private static Stream generateArguments() { + return Stream.of(Arguments.of(NAME, UNIX_MODE, true, false, false), + Arguments.of(NAME, UNIX_MODE, false, true, false), Arguments.of(NAME, UNIX_MODE, false, false, true)); } } diff --git a/site/src/components/link/property-link.vue b/site/src/components/link/property-link.vue index ef3eb9d1..998a0325 100644 --- a/site/src/components/link/property-link.vue +++ b/site/src/components/link/property-link.vue @@ -1,6 +1,7 @@ diff --git a/site/src/components/property/max-download-attempts-property.vue b/site/src/components/property/max-download-attempts-property.vue new file mode 100644 index 00000000..d729c0ac --- /dev/null +++ b/site/src/components/property/max-download-attempts-property.vue @@ -0,0 +1,52 @@ + + + diff --git a/site/src/components/property/property.vue b/site/src/components/property/property.vue index 3b7e92d1..d9d4a656 100644 --- a/site/src/components/property/property.vue +++ b/site/src/components/property/property.vue @@ -18,14 +18,10 @@
  • Type: - - + + {{ type }}
  • Required: {{ required }} @@ -52,7 +48,7 @@ import fgpPropertyLinkAnchor from '@/components/link/property-link-anchor'; import fgpSiteLink from '@/components/link/site-link'; import fgpTaskLink from '@/components/link/task-link'; -const QUALIFIED_JDK_CLASS_NAME_REGEXP = /^javax?\.([a-z]\w+\.)+[A-Z]\w+$/; +const QUALIFIED_JDK_CLASS_NAME_REGEXP = /^(?javax?\.(?:[a-z]\w+\.)+[A-Z]\w+?)(?:<[\w.]+>)?$/; const JDK_STRING_CLASS_NAME = 'java.lang.String'; export default Vue.component('fgp-property', { @@ -91,8 +87,11 @@ export default Vue.component('fgp-property', { return this.type === JDK_STRING_CLASS_NAME ? `"${this.defaultValue}"` : this.defaultValue; }, jdkHref() { - if (this.type && QUALIFIED_JDK_CLASS_NAME_REGEXP.test(this.type)) { - return `https://docs.oracle.com/javase/8/docs/api/index.html?${this.type.replace(/\./, '/')}.html`; + if (this.type) { + const matches = QUALIFIED_JDK_CLASS_NAME_REGEXP.exec(this.type); + if (matches && matches.groups.fqcn) { + return `https://docs.oracle.com/en/java/javase/17/docs/api/java.base/${matches.groups.fqcn.replace(/\./g, '/')}.html`; + } } return null; }, diff --git a/site/src/components/property/retry-http-statuses-property.vue b/site/src/components/property/retry-http-statuses-property.vue new file mode 100644 index 00000000..4dfaa30d --- /dev/null +++ b/site/src/components/property/retry-http-statuses-property.vue @@ -0,0 +1,23 @@ + + + diff --git a/site/src/components/property/retry-initial-interval-ms-property.vue b/site/src/components/property/retry-initial-interval-ms-property.vue new file mode 100644 index 00000000..98f3f273 --- /dev/null +++ b/site/src/components/property/retry-initial-interval-ms-property.vue @@ -0,0 +1,23 @@ + + + diff --git a/site/src/components/property/retry-interval-multiplier-property.vue b/site/src/components/property/retry-interval-multiplier-property.vue new file mode 100644 index 00000000..77fa0b21 --- /dev/null +++ b/site/src/components/property/retry-interval-multiplier-property.vue @@ -0,0 +1,22 @@ + + + diff --git a/site/src/components/property/retry-max-interval-ms-property.vue b/site/src/components/property/retry-max-interval-ms-property.vue new file mode 100644 index 00000000..dde07f77 --- /dev/null +++ b/site/src/components/property/retry-max-interval-ms-property.vue @@ -0,0 +1,23 @@ + + + diff --git a/site/src/pages/configuration.vue b/site/src/pages/configuration.vue index dc24333b..9d235ba7 100644 --- a/site/src/pages/configuration.vue +++ b/site/src/pages/configuration.vue @@ -47,6 +47,11 @@ = 80 = 'username' = 'password' + = 1 + = [408, 429, 500, 502, 503, 504] + = 1000 + = 2.0 + = 30000 = false = file("${projectDir}/.frontend-gradle-plugin") } @@ -89,6 +94,11 @@ .set(80) .set("username") .set("password") + .set(1) + .set(setOf(408, 429, 500, 502, 503, 504)) + .set(1000) + .set(2.0) + .set(30000) .set(false) .set(project.layout.projectDirectory.dir(".frontend-gradle-plugin")) } @@ -198,6 +208,11 @@ assembleScript.set("run build") + + + + + @@ -268,9 +283,18 @@ import fgpCleanScriptProperty from '@/components/property/clean-script-property' import fgpCode from '@/components/code'; import fgpCodeComment from '@/components/code-comment'; import fgpGradleScripts from '@/components/gradle-scripts'; +import fgpHttpProxyHostProperty from '@/components/property/http-proxy-host-property'; +import fgpHttpProxyPasswordProperty from '@/components/property/http-proxy-password-property'; +import fgpHttpProxyPortProperty from '@/components/property/http-proxy-port-property'; +import fgpHttpProxyUsernameProperty from '@/components/property/http-proxy-username-property'; +import fgpHttpsProxyHostProperty from '@/components/property/https-proxy-host-property'; +import fgpHttpsProxyPasswordProperty from '@/components/property/https-proxy-password-property'; +import fgpHttpsProxyPortProperty from '@/components/property/https-proxy-port-property'; +import fgpHttpsProxyUsernameProperty from '@/components/property/https-proxy-username-property'; import fgpInstallScriptProperty from '@/components/property/install-script-property'; import fgpJavaNetworkPropertiesLink from '@/components/link/java-network-properties-link'; import fgpMainTitle from '@/components/main-title'; +import fgpMaxDownloadAttemptsProperty from '@/components/property/max-download-attempts-property'; import fgpNodeDistributionProvidedProperty from '@/components/property/node-distribution-provided-property'; import fgpNodeVersionProperty from '@/components/property/node-version-property'; import fgpNodeDistributionUrlRootProperty from '@/components/property/node-distribution-url-root-property'; @@ -281,15 +305,11 @@ import fgpNodeInstallDirectoryProperty from '@/components/property/node-install- import fgpPageMeta from '@/mixin/page-meta'; import fgpPackageJsonDirectoryProperty from '@/components/property/package-json-directory-property'; import fgpPropertyLink from '@/components/link/property-link'; -import fgpHttpProxyHostProperty from '@/components/property/http-proxy-host-property'; -import fgpHttpProxyPasswordProperty from '@/components/property/http-proxy-password-property'; -import fgpHttpProxyPortProperty from '@/components/property/http-proxy-port-property'; -import fgpHttpProxyUsernameProperty from '@/components/property/http-proxy-username-property'; -import fgpHttpsProxyHostProperty from '@/components/property/https-proxy-host-property'; -import fgpHttpsProxyPasswordProperty from '@/components/property/https-proxy-password-property'; -import fgpHttpsProxyPortProperty from '@/components/property/https-proxy-port-property'; -import fgpHttpsProxyUsernameProperty from '@/components/property/https-proxy-username-property'; import fgpPublishScriptProperty from '@/components/property/publish-script-property'; +import fgpRetryHttpStatusesProperty from '@/components/property/retry-http-statuses-property'; +import fgpRetryInitialIntervalMsProperty from '@/components/property/retry-initial-interval-ms-property'; +import fgpRetryIntervalMultiplierProperty from '@/components/property/retry-interval-multiplier-property'; +import fgpRetryMaxIntervalMsProperty from '@/components/property/retry-max-interval-ms-property'; import fgpSubSubTitle from '@/components/sub-sub-title'; import fgpSubTitle from '@/components/sub-title'; import fgpVerboseModeEnabledProperty from '@/components/property/verbose-mode-enabled-property'; @@ -314,6 +334,7 @@ export default Vue.component('fgp-configuration', { fgpInstallScriptProperty, fgpJavaNetworkPropertiesLink, fgpMainTitle, + fgpMaxDownloadAttemptsProperty, fgpNodeDistributionProvidedProperty, fgpNodeVersionProperty, fgpNodeDistributionUrlRootProperty, @@ -332,6 +353,10 @@ export default Vue.component('fgp-configuration', { fgpHttpsProxyPortProperty, fgpHttpsProxyUsernameProperty, fgpPublishScriptProperty, + fgpRetryHttpStatusesProperty, + fgpRetryInitialIntervalMsProperty, + fgpRetryIntervalMultiplierProperty, + fgpRetryMaxIntervalMsProperty, fgpSubSubTitle, fgpSubTitle, fgpVerboseModeEnabledProperty, diff --git a/site/src/pages/index.vue b/site/src/pages/index.vue index 1fab59cb..10993073 100644 --- a/site/src/pages/index.vue +++ b/site/src/pages/index.vue @@ -32,11 +32,12 @@ By default, the plugin downloads and installs a distribution. Multiple Gradle - (sub-)projects may use the distribution downloaded by one of them, or even use a distribution already + (sub)projects may use the distribution downloaded by one of them, or even use a distribution already installed in the workstation to avoid network overhead and duplication. The plugin may also use a HTTP proxy server when downloading the distribution, to take advantage of any caching facility, and submit to the organization's security rules. Basic authentication scheme is supported for both distribution - and proxy servers. + and proxy servers. In case of connectivity issues, downloading the distribution is + also retryable, with a configurable exponential backoff strategy. The plugin delegates installation of your favorite package manager to . Choose your