From 1e83c34823820a73bfa45ca419fb53df92c2d8c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 21 Apr 2021 10:09:23 +0200 Subject: [PATCH] Enable Health Checks (#1621) In the release of version 2.0.0 of SCM-Manager, the health checks had been neglected. This makes them visible again in the frontend and adds the ability to trigger them. In addition there are two types of health checks: The "normal" ones, now called "light checks", that are run on startup, and more intense checks run only on request. As a change to version 1.x, health checks will no longer be persisted for repositories. Co-authored-by: Eduard Heimbuch --- ...pository-settings-general-health-check.png | Bin 0 -> 19948 bytes docs/de/user/repo/settings.md | 18 +- ...pository-settings-general-health-check.png | Bin 0 -> 16761 bytes docs/en/user/repo/settings.md | 13 + gradle/changelog/health_checks.yaml | 2 + .../java/sonia/scm/SCMContextProvider.java | 20 +- .../scm/repository/HealthCheckEvent.java | 59 ++++ .../scm/repository/HealthCheckFailure.java | 97 ++++-- .../scm/repository/MetadataHealthCheck.java | 63 ++++ .../java/sonia/scm/repository/Repository.java | 4 +- .../sonia/scm/repository/api/Command.java | 11 +- .../api/FullHealthCheckCommandBuilder.java | 43 +++ .../scm/repository/api/RepositoryService.java | 15 + .../spi/FullHealthCheckCommand.java | 33 +++ .../spi/RepositoryServiceProvider.java | 13 +- .../sonia/scm/SCMContextProviderTest.java | 67 +++++ .../repository/HealthCheckFailureTest.java | 62 ++++ .../spi/HgFullHealthCheckCommand.java | 55 ++++ .../spi/HgRepositoryServiceProvider.java | 8 +- .../scm/repository/spi/HgVerifyCommand.java | 50 ++++ .../spi/HgFullHealthCheckCommandTest.java | 57 ++++ .../spi/SvnFullHealthCheckCommand.java | 57 ++++ .../spi/SvnRepositoryServiceProvider.java | 17 +- .../spi/SvnFullHealthCheckCommandTest.java | 52 ++++ scm-ui/ui-api/src/repositories.ts | 21 ++ scm-ui/ui-components/src/modals/Modal.tsx | 14 +- .../src/repos/HealthCheckFailureDetail.tsx | 60 ++++ .../src/repos/HealthCheckFailureList.tsx | 69 +++++ .../src/repos/RepositoryEntry.tsx | 65 +++- scm-ui/ui-components/src/repos/index.ts | 1 + scm-ui/ui-types/src/Repositories.ts | 9 + scm-ui/ui-webapp/public/locales/de/repos.json | 20 +- scm-ui/ui-webapp/public/locales/en/repos.json | 20 +- .../src/repos/containers/EditRepo.tsx | 10 +- .../repos/containers/HealthCheckWarning.tsx | 62 ++++ .../repos/containers/RepositoryDangerZone.tsx | 1 - .../src/repos/containers/RepositoryRoot.tsx | 32 +- .../src/repos/containers/RunHealthCheck.tsx | 72 +++++ .../v2/resources/HealthCheckFailureDto.java | 17 +- .../scm/api/v2/resources/RepositoryDto.java | 1 + .../api/v2/resources/RepositoryResource.java | 24 +- .../RepositoryToRepositoryDtoMapper.java | 28 +- .../scm/api/v2/resources/ResourceLinks.java | 4 + .../lifecycle/modules/ScmServletModule.java | 4 + .../repository/DefaultHealthCheckService.java | 59 ++++ .../repository/DefaultRepositoryManager.java | 20 +- .../HealthCheckContextListener.java | 4 +- .../scm/repository/HealthCheckService.java | 36 +++ .../sonia/scm/repository/HealthChecker.java | 159 ++++++++-- .../repository/RepositoryPostProcessor.java | 66 +++++ .../resources/META-INF/scm/permissions.xml | 3 + .../META-INF/scm/repository-permissions.xml | 1 + .../main/resources/locales/de/plugins.json | 69 +++++ .../main/resources/locales/en/plugins.json | 13 + .../resources/RepositoryRootResourceTest.java | 3 + .../api/v2/resources/RepositoryTestBase.java | 8 +- .../RepositoryToRepositoryDtoMapperTest.java | 63 ++++ .../DefaultRepositoryManagerPerfTest.java | 6 +- .../DefaultRepositoryManagerTest.java | 9 +- .../scm/repository/HealthCheckerTest.java | 278 ++++++++++++++++++ .../RepositoryPostProcessorTest.java | 121 ++++++++ 61 files changed, 2162 insertions(+), 106 deletions(-) create mode 100644 docs/de/user/repo/assets/repository-settings-general-health-check.png create mode 100644 docs/en/user/repo/assets/repository-settings-general-health-check.png create mode 100644 gradle/changelog/health_checks.yaml create mode 100644 scm-core/src/main/java/sonia/scm/repository/HealthCheckEvent.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/MetadataHealthCheck.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/api/FullHealthCheckCommandBuilder.java create mode 100644 scm-core/src/main/java/sonia/scm/repository/spi/FullHealthCheckCommand.java create mode 100644 scm-core/src/test/java/sonia/scm/SCMContextProviderTest.java create mode 100644 scm-core/src/test/java/sonia/scm/repository/HealthCheckFailureTest.java create mode 100644 scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgFullHealthCheckCommand.java create mode 100644 scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgVerifyCommand.java create mode 100644 scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgFullHealthCheckCommandTest.java create mode 100644 scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnFullHealthCheckCommand.java create mode 100644 scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnFullHealthCheckCommandTest.java create mode 100644 scm-ui/ui-components/src/repos/HealthCheckFailureDetail.tsx create mode 100644 scm-ui/ui-components/src/repos/HealthCheckFailureList.tsx create mode 100644 scm-ui/ui-webapp/src/repos/containers/HealthCheckWarning.tsx create mode 100644 scm-ui/ui-webapp/src/repos/containers/RunHealthCheck.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/DefaultHealthCheckService.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/HealthCheckService.java create mode 100644 scm-webapp/src/main/java/sonia/scm/repository/RepositoryPostProcessor.java create mode 100644 scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/repository/RepositoryPostProcessorTest.java diff --git a/docs/de/user/repo/assets/repository-settings-general-health-check.png b/docs/de/user/repo/assets/repository-settings-general-health-check.png new file mode 100644 index 0000000000000000000000000000000000000000..0b15e1b1c9bfa66a90701c5de3da4026f49ad68d GIT binary patch literal 19948 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

x8aju21nk{M)ET) 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%0Ai6bu>ArbPCw4romn(b(h>f{QE`l@V?MVt2olT0f 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 previousFailures; + private final Collection currentFailures; + + public Repository getRepository() { + return repository; + } + + public Collection getPreviousFailures() { + return unmodifiableCollection(previousFailures); + } + + public Collection getCurrentFailures() { + return unmodifiableCollection(currentFailures); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/HealthCheckFailure.java b/scm-core/src/main/java/sonia/scm/repository/HealthCheckFailure.java index 729a49b65f..d53259a6ed 100644 --- a/scm-core/src/main/java/sonia/scm/repository/HealthCheckFailure.java +++ b/scm-core/src/main/java/sonia/scm/repository/HealthCheckFailure.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -32,6 +32,7 @@ import com.google.common.base.Objects; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlRootElement; +import java.text.MessageFormat; //~--- JDK imports ------------------------------------------------------------ @@ -46,15 +47,18 @@ import javax.xml.bind.annotation.XmlRootElement; public final class HealthCheckFailure { + private static final String URL_TEMPLATE = "https://www.scm-manager.org/docs/{0}/en/user/repo/health-checks/%s"; + private static final String LATEST_VERSION = "latest"; + /** - * Constructs a new {@link HealthCheckFailure}. + * Constructs a new {@link HealthCheckFailure}. * This constructor is only for JAXB. * */ HealthCheckFailure() {} /** - * Constructs a new {@link HealthCheckFailure}. + * Constructs a new {@link HealthCheckFailure}. * * @param id id of the failure * @param summary summary of the failure @@ -62,7 +66,7 @@ public final class HealthCheckFailure */ public HealthCheckFailure(String id, String summary, String description) { - this(id, summary, null, description); + this(id, summary, (String) null, description); } /** @@ -79,14 +83,49 @@ public final class HealthCheckFailure this.id = id; this.summary = summary; this.url = url; + this.urlTemplated = false; this.description = description; } - //~--- methods -------------------------------------------------------------- + /** + * Constructs ... + * + * @param id id of the failure + * @param summary summary of the failure + * @param urlTemplate template for the url of the failure (use {@link #urlForTitle(String)} to create this) + * @param description description of the failure + * @since 2.17.0 + */ + public HealthCheckFailure(String id, String summary, UrlTemplate urlTemplate, + String description) + { + this.id = id; + this.summary = summary; + this.url = urlTemplate.get(); + this.urlTemplated = true; + this.description = description; + } /** - * {@inheritDoc} + * Use this to create {@link HealthCheckFailure} instances with an url for core health check failures. + * @param title The title of the failure matching a health check documentation page. + * @since 2.17.0 */ + public static UrlTemplate urlForTitle(String title) { + return new UrlTemplate(String.format(URL_TEMPLATE, title)); + } + + /** + * Use this to create {@link HealthCheckFailure} instances with a custom url for core health check + * failures. If this url can be customized with a concrete version of SCM-Manager, you can use {0} + * as a placeholder for the version. This will be replaces later on. + * @param urlTemplate The url for this failure. + * @since 2.17.0 + */ + public static UrlTemplate templated(String urlTemplate) { + return new UrlTemplate(urlTemplate); + } + @Override public boolean equals(Object obj) { @@ -103,25 +142,19 @@ public final class HealthCheckFailure final HealthCheckFailure other = (HealthCheckFailure) obj; //J- - return Objects.equal(id, other.id) + return Objects.equal(id, other.id) && Objects.equal(summary, other.summary) && Objects.equal(url, other.url) && Objects.equal(description, other.description); //J+ } - /** - * {@inheritDoc} - */ @Override public int hashCode() { return Objects.hashCode(id, summary, url, description); } - /** - * {@inheritDoc} - */ @Override public String toString() { @@ -135,8 +168,6 @@ public final class HealthCheckFailure //J+ } - //~--- get methods ---------------------------------------------------------- - /** * Returns the description of this failure. * @@ -168,16 +199,31 @@ public final class HealthCheckFailure } /** - * Return the url of the failure. + * Return the url of the failure. The url may potentially be templated. In the case you can get a + * special url for an explicit version of SCM-Manager using {@link #getUrl(String)} whereas this + * function will return a generic url for the {@value LATEST_VERSION} version. * * @return url of the failure */ public String getUrl() { - return url; + return getUrl(LATEST_VERSION); } - //~--- fields --------------------------------------------------------------- + /** + * Return the url of the failure for a concrete version of SCM-Manager (given the url is templated). + * + * @param version The version of SCM-Manager to create the url for. + * @return url of the failure + * @since 2.17.0 + */ + public String getUrl(String version) { + if (urlTemplated) { + return MessageFormat.format(url, version); + } else { + return url; + } + } /** description of failure */ private String description; @@ -190,4 +236,19 @@ public final class HealthCheckFailure /** url of failure */ private String url; + + /** Flag whether the url is a template or not */ + private boolean urlTemplated = false; + + public static final class UrlTemplate { + private final String url; + + private UrlTemplate(String url) { + this.url = url; + } + + private String get() { + return url; + } + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/MetadataHealthCheck.java b/scm-core/src/main/java/sonia/scm/repository/MetadataHealthCheck.java new file mode 100644 index 0000000000..17825277fe --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/MetadataHealthCheck.java @@ -0,0 +1,63 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; + +@Extension +public final class MetadataHealthCheck implements HealthCheck { + + public static final HealthCheckFailure REPOSITORY_Directory_NOT_WRITABLE = + new HealthCheckFailure("9cSV1eaVF1", + "repository directory not writable", + "The system user has no permissions to create or delete files in the repository directory."); + public static final HealthCheckFailure METADATA_NOT_WRITABLE = + new HealthCheckFailure("6bSUg4dZ41", + "metadata file not writable", + "The system user has no permissions to modify the metadata file for the repository."); + private final RepositoryLocationResolver locationResolver; + + @Inject + public MetadataHealthCheck(RepositoryLocationResolver locationResolver) { + this.locationResolver = locationResolver; + } + + @Override + public HealthCheckResult check(Repository repository) { + Path repositoryLocation = locationResolver.forClass(Path.class).getLocation(repository.getId()); + if (!Files.isWritable(repositoryLocation)) { + return HealthCheckResult.unhealthy(REPOSITORY_Directory_NOT_WRITABLE); + } + Path metadata = repositoryLocation.resolve("metadata.xml"); + if (!Files.isWritable(metadata)) { + return HealthCheckResult.unhealthy(METADATA_NOT_WRITABLE); + } + return HealthCheckResult.healthy(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/Repository.java b/scm-core/src/main/java/sonia/scm/repository/Repository.java index 5382d28a75..59cabbbe38 100644 --- a/scm-core/src/main/java/sonia/scm/repository/Repository.java +++ b/scm-core/src/main/java/sonia/scm/repository/Repository.java @@ -37,7 +37,6 @@ import sonia.scm.util.ValidationUtil; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlElement; -import javax.xml.bind.annotation.XmlElementWrapper; import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlTransient; import java.util.Arrays; @@ -69,8 +68,7 @@ public class Repository extends BasicPropertiesAware implements ModelObject, Per private String contact; private Long creationDate; private String description; - @XmlElement(name = "healthCheckFailure") - @XmlElementWrapper(name = "healthCheckFailures") + @XmlTransient private List healthCheckFailures; private String id; private Long lastModified; diff --git a/scm-core/src/main/java/sonia/scm/repository/api/Command.java b/scm-core/src/main/java/sonia/scm/repository/api/Command.java index e96233a5f3..5a97709888 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/Command.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/Command.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.api; /** @@ -48,7 +48,7 @@ public enum Command * @since 1.31 */ INCOMING, OUTGOING, PUSH, PULL, - + /** * @since 1.43 */ @@ -67,5 +67,10 @@ public enum Command /** * @since 2.11.0 */ - TAG; + TAG, + + /** + * @since 2.17.0 + */ + FULL_HEALTH_CHECK; } diff --git a/scm-core/src/main/java/sonia/scm/repository/api/FullHealthCheckCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/FullHealthCheckCommandBuilder.java new file mode 100644 index 0000000000..11c1584766 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/api/FullHealthCheckCommandBuilder.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.api; + +import sonia.scm.repository.HealthCheckResult; +import sonia.scm.repository.spi.FullHealthCheckCommand; + +import java.io.IOException; + +public class FullHealthCheckCommandBuilder { + + private final FullHealthCheckCommand command; + + public FullHealthCheckCommandBuilder(FullHealthCheckCommand command) { + this.command = command; + } + + public HealthCheckResult check() throws IOException { + return command.check(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java index 4773deb325..66185e75d9 100644 --- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java +++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryService.java @@ -466,6 +466,21 @@ public final class RepositoryService implements Closeable { return new LookupCommandBuilder(provider.getLookupCommand()); } + /** + * The full health check command inspects a repository in a way, that might take a while in contrast to the + * light checks executed at startup. + * + * @return instance of {@link FullHealthCheckCommandBuilder} + * @throws CommandNotSupportedException if the command is not supported + * by the implementation of the repository service provider. + * @since 2.17.0 + */ + public FullHealthCheckCommandBuilder getFullCheckCommand() { + LOG.debug("create full check command for repository {}", + repository.getNamespaceAndName()); + return new FullHealthCheckCommandBuilder(provider.getFullHealthCheckCommand()); + } + /** * Returns true if the command is supported by the repository service. * diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/FullHealthCheckCommand.java b/scm-core/src/main/java/sonia/scm/repository/spi/FullHealthCheckCommand.java new file mode 100644 index 0000000000..2af7786d37 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/spi/FullHealthCheckCommand.java @@ -0,0 +1,33 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import sonia.scm.repository.HealthCheckResult; + +import java.io.IOException; + +public interface FullHealthCheckCommand { + HealthCheckResult check() throws IOException; +} diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java index 4e56cc57f9..d96a904d33 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/RepositoryServiceProvider.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository.spi; import sonia.scm.repository.Feature; @@ -119,7 +119,7 @@ public abstract class RepositoryServiceProvider implements Closeable * * * @return - * + * * @since 1.43 */ public BundleCommand getBundleCommand() @@ -260,7 +260,7 @@ public abstract class RepositoryServiceProvider implements Closeable * * * @return - * + * * @since 1.43 */ public UnbundleCommand getUnbundleCommand() @@ -291,4 +291,11 @@ public abstract class RepositoryServiceProvider implements Closeable { throw new CommandNotSupportedException(Command.LOOKUP); } + + /** + * @since 2.17.0 + */ + public FullHealthCheckCommand getFullHealthCheckCommand() { + throw new CommandNotSupportedException(Command.FULL_HEALTH_CHECK); + } } diff --git a/scm-core/src/test/java/sonia/scm/SCMContextProviderTest.java b/scm-core/src/test/java/sonia/scm/SCMContextProviderTest.java new file mode 100644 index 0000000000..a2d65e325a --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/SCMContextProviderTest.java @@ -0,0 +1,67 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class SCMContextProviderTest { + + @Test + void shouldCreateCorrectDocumentationVersion() { + SCMContextProvider scmContextProvider = new SCMContextProvider() { + @Override + public File getBaseDirectory() { + return null; + } + + @Override + public Path resolve(Path path) { + return null; + } + + @Override + public Stage getStage() { + return null; + } + + @Override + public Throwable getStartupError() { + return null; + } + + @Override + public String getVersion() { + return "1.17.2"; + } + }; + + assertThat(scmContextProvider.getDocumentationVersion()).isEqualTo("1.17.x"); + } +} diff --git a/scm-core/src/test/java/sonia/scm/repository/HealthCheckFailureTest.java b/scm-core/src/test/java/sonia/scm/repository/HealthCheckFailureTest.java new file mode 100644 index 0000000000..e25de7d456 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/repository/HealthCheckFailureTest.java @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static sonia.scm.repository.HealthCheckFailure.templated; +import static sonia.scm.repository.HealthCheckFailure.urlForTitle; + +class HealthCheckFailureTest { + + @Test + void shouldCreateTemplatedUrl() { + HealthCheckFailure failure = new HealthCheckFailure("1", "hyperdrive", urlForTitle("hyperdrive"), "Far too fast"); + + assertThat(failure.getUrl()).isEqualTo("https://www.scm-manager.org/docs/latest/en/user/repo/health-checks/hyperdrive"); + } + + @Test + void shouldCreateTemplatedUrlForGivenVersion() { + HealthCheckFailure failure = new HealthCheckFailure("1", "hyperdrive", urlForTitle("hyperdrive"), "Far too fast"); + + assertThat(failure.getUrl("1.17.x")).isEqualTo("https://www.scm-manager.org/docs/1.17.x/en/user/repo/health-checks/hyperdrive"); + } + + @Test + void shouldCreateCustomTemplatedUrlForGivenVersion() { + HealthCheckFailure failure = new HealthCheckFailure("1", "hyperdrive", templated("http://hog/{0}/error"), "Far too fast"); + + assertThat(failure.getUrl("1.17.x")).isEqualTo("http://hog/1.17.x/error"); + } + + @Test + void shouldReturnNullForUrlIfNotSet() { + HealthCheckFailure failure = new HealthCheckFailure("1", "hyperdrive", "Far too fast"); + + assertThat(failure.getUrl()).isNull(); + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgFullHealthCheckCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgFullHealthCheckCommand.java new file mode 100644 index 0000000000..cc021ba099 --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgFullHealthCheckCommand.java @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.aragost.javahg.commands.ExecutionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.HealthCheckResult; + +import java.io.IOException; + +public class HgFullHealthCheckCommand extends AbstractCommand implements FullHealthCheckCommand { + + private static final Logger LOG = LoggerFactory.getLogger(HgFullHealthCheckCommand.class); + + public HgFullHealthCheckCommand(HgCommandContext context) { + super(context); + } + + @Override + public HealthCheckResult check() throws IOException { + HgVerifyCommand cmd = HgVerifyCommand.on(open()); + try { + cmd.execute(); + return HealthCheckResult.healthy(); + } catch (ExecutionException e) { + LOG.warn("hg verify failed for repository {}", getRepository(), e); + return HealthCheckResult.unhealthy(new HealthCheckFailure("FaSUYbZUR1", + "hg verify failed", "The check 'hg verify' failed for the repository.")); + } + } +} diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java index c469fb4079..4e9b924e26 100644 --- a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgRepositoryServiceProvider.java @@ -59,7 +59,8 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { Command.PULL, Command.MODIFY, Command.BUNDLE, - Command.UNBUNDLE + Command.UNBUNDLE, + Command.FULL_HEALTH_CHECK ); public static final Set FEATURES = EnumSet.of(Feature.COMBINED_DEFAULT_BRANCH); @@ -188,4 +189,9 @@ public class HgRepositoryServiceProvider extends RepositoryServiceProvider { public UnbundleCommand getUnbundleCommand() { return new HgUnbundleCommand(context, lazyChangesetResolver, eventFactory); } + + @Override + public FullHealthCheckCommand getFullHealthCheckCommand() { + return new HgFullHealthCheckCommand(context); + } } diff --git a/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgVerifyCommand.java b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgVerifyCommand.java new file mode 100644 index 0000000000..59b17c057d --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/main/java/sonia/scm/repository/spi/HgVerifyCommand.java @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.aragost.javahg.Repository; +import com.aragost.javahg.internals.AbstractCommand; + +public class HgVerifyCommand extends AbstractCommand { + + public static final String COMMAND_NAME = "verify"; + + protected HgVerifyCommand(Repository repository) { + super(repository, COMMAND_NAME); + } + + public static HgVerifyCommand on(Repository repository) { + return new HgVerifyCommand(repository); + } + + public String execute() { + return launchString(); + } + + @Override + public String getCommandName() { + return COMMAND_NAME; + } +} diff --git a/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgFullHealthCheckCommandTest.java b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgFullHealthCheckCommandTest.java new file mode 100644 index 0000000000..6e5697c56d --- /dev/null +++ b/scm-plugins/scm-hg-plugin/src/test/java/sonia/scm/repository/spi/HgFullHealthCheckCommandTest.java @@ -0,0 +1,57 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import com.aragost.javahg.commands.ExecutionException; +import org.junit.Test; +import sonia.scm.repository.HealthCheckResult; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class HgFullHealthCheckCommandTest extends AbstractHgCommandTestBase { + + @Test + public void shouldDetectMissingFile() throws IOException { + HgFullHealthCheckCommand checkCommand = new HgFullHealthCheckCommand(cmdContext); + File d = new File(cmdContext.open().getDirectory(), ".hg/store/data/c/d.txt.i"); + d.delete(); + + HealthCheckResult check = checkCommand.check(); + + assertThat(check.isHealthy()).isFalse(); + } + + @Test + public void shouldBeOkForValidRepository() throws IOException { + HgFullHealthCheckCommand checkCommand = new HgFullHealthCheckCommand(cmdContext); + + HealthCheckResult check = checkCommand.check(); + + assertThat(check.isHealthy()).isTrue(); + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnFullHealthCheckCommand.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnFullHealthCheckCommand.java new file mode 100644 index 0000000000..2e7441a9b9 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnFullHealthCheckCommand.java @@ -0,0 +1,57 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.tmatesoft.svn.core.SVNException; +import org.tmatesoft.svn.core.wc.SVNClientManager; +import org.tmatesoft.svn.core.wc.admin.SVNAdminClient; +import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.HealthCheckResult; + +public class SvnFullHealthCheckCommand extends AbstractSvnCommand implements FullHealthCheckCommand{ + + private static final Logger LOG = LoggerFactory.getLogger(SvnFullHealthCheckCommand.class); + + protected SvnFullHealthCheckCommand(SvnContext context) { + super(context); + } + + @Override + public HealthCheckResult check() { + SVNClientManager clientManager= SVNClientManager.newInstance(); + SVNAdminClient adminClient = clientManager.getAdminClient(); + try { + adminClient.doVerify(context.getDirectory()); + } catch (SVNException e) { + LOG.warn("svn verify failed for repository {}", context.get(), e); + return HealthCheckResult.unhealthy(new HealthCheckFailure("5FSV2kreE1", + "svn verify failed", "The check 'svn verify' failed for the repository.")); + } + + return HealthCheckResult.healthy(); + } +} diff --git a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java index 3ef452eb01..c286891a4d 100644 --- a/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java +++ b/scm-plugins/scm-svn-plugin/src/main/java/sonia/scm/repository/spi/SvnRepositoryServiceProvider.java @@ -42,8 +42,16 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { //J- public static final Set COMMANDS = ImmutableSet.of( - Command.BLAME, Command.BROWSE, Command.CAT, Command.DIFF, - Command.LOG, Command.BUNDLE, Command.UNBUNDLE, Command.MODIFY, Command.LOOKUP + Command.BLAME, + Command.BROWSE, + Command.CAT, + Command.DIFF, + Command.LOG, + Command.BUNDLE, + Command.UNBUNDLE, + Command.MODIFY, + Command.LOOKUP, + Command.FULL_HEALTH_CHECK ); //J+ @@ -120,4 +128,9 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider { public UnbundleCommand getUnbundleCommand() { return new SvnUnbundleCommand(context, hookContextFactory, new SvnLogCommand(context)); } + + @Override + public FullHealthCheckCommand getFullHealthCheckCommand() { + return new SvnFullHealthCheckCommand(context); + } } diff --git a/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnFullHealthCheckCommandTest.java b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnFullHealthCheckCommandTest.java new file mode 100644 index 0000000000..3a13752875 --- /dev/null +++ b/scm-plugins/scm-svn-plugin/src/test/java/sonia/scm/repository/spi/SvnFullHealthCheckCommandTest.java @@ -0,0 +1,52 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository.spi; + +import org.junit.Test; +import sonia.scm.repository.HealthCheckResult; + +import java.io.File; + +import static org.assertj.core.api.Assertions.assertThat; + +public class SvnFullHealthCheckCommandTest extends AbstractSvnCommandTestBase { + + @Test + public void shouldBeOkForValidRepository() { + HealthCheckResult check = new SvnFullHealthCheckCommand(createContext()).check(); + + assertThat(check.isHealthy()).isTrue(); + } + + @Test + public void shouldDetectMissingFile() { + File revision4 = new File(createContext().getDirectory(), "db/revs/0/4"); + revision4.delete(); + + HealthCheckResult check = new SvnFullHealthCheckCommand(createContext()).check(); + + assertThat(check.isHealthy()).isFalse(); + } +} diff --git a/scm-ui/ui-api/src/repositories.ts b/scm-ui/ui-api/src/repositories.ts index 08b18289cc..3f14afe2ad 100644 --- a/scm-ui/ui-api/src/repositories.ts +++ b/scm-ui/ui-api/src/repositories.ts @@ -232,6 +232,27 @@ export const useUnarchiveRepository = () => { }; }; +export const useRunHealthCheck = () => { + const queryClient = useQueryClient(); + const { mutate, isLoading, error, data } = useMutation( + repository => { + const link = requiredLink(repository, "runHealthCheck"); + return apiClient.post(link); + }, + { + onSuccess: async (_, repository) => { + await queryClient.invalidateQueries(repoQueryKey(repository)); + } + } + ); + return { + runHealthCheck: (repository: Repository) => mutate(repository), + isLoading, + error, + isRunning: !!data + }; +}; + export const useExportInfo = (repository: Repository): ApiResult => { const link = requiredLink(repository, "exportInfo"); //TODO Refetch while exporting to update the page diff --git a/scm-ui/ui-components/src/modals/Modal.tsx b/scm-ui/ui-components/src/modals/Modal.tsx index 21825e5c75..9489b46319 100644 --- a/scm-ui/ui-components/src/modals/Modal.tsx +++ b/scm-ui/ui-components/src/modals/Modal.tsx @@ -35,9 +35,19 @@ type Props = { active: boolean; className?: string; headColor?: string; + headTextColor?: string; }; -export const Modal: FC = ({ title, closeFunction, body, footer, active, className, headColor = "light" }) => { +export const Modal: FC = ({ + title, + closeFunction, + body, + footer, + active, + className, + headColor = "light", + headTextColor = "black" +}) => { const portalRootElement = usePortalRootElement("modalsRoot"); if (!portalRootElement) { @@ -56,7 +66,7 @@ export const Modal: FC = ({ title, closeFunction, body, footer, active, c

-

{title}

+

{title}

{body}
diff --git a/scm-ui/ui-components/src/repos/HealthCheckFailureDetail.tsx b/scm-ui/ui-components/src/repos/HealthCheckFailureDetail.tsx new file mode 100644 index 0000000000..bf6494652c --- /dev/null +++ b/scm-ui/ui-components/src/repos/HealthCheckFailureDetail.tsx @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC } from "react"; +import { Modal } from "../modals"; +import { HealthCheckFailure } from "@scm-manager/ui-types"; +import { useTranslation } from "react-i18next"; +import { Button } from "../buttons"; +import HealthCheckFailureList from "./HealthCheckFailureList"; + +type Props = { + active: boolean; + closeFunction: () => void; + failures?: HealthCheckFailure[]; +}; + +const HealthCheckFailureDetail: FC = ({ active, closeFunction, failures }) => { + const [t] = useTranslation("repos"); + + const footer =
+ } + title={t("healthCheckFailure.title")} + closeFunction={closeFunction} + active={active} + footer={footer} + headColor={"danger"} + headTextColor={"white"} + /> + ); +}; + +export default HealthCheckFailureDetail; diff --git a/scm-ui/ui-components/src/repos/HealthCheckFailureList.tsx b/scm-ui/ui-components/src/repos/HealthCheckFailureList.tsx new file mode 100644 index 0000000000..111bc4384e --- /dev/null +++ b/scm-ui/ui-components/src/repos/HealthCheckFailureList.tsx @@ -0,0 +1,69 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { HealthCheckFailure } from "@scm-manager/ui-types"; +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; + +type Props = { + failures?: HealthCheckFailure[]; +}; + +const HealthCheckFailureList: FC = ({ failures }) => { + const [t] = useTranslation("plugins"); + + const translationOrDefault = (translationKey: string, defaultValue: string) => { + const translation = t(translationKey); + return translation === translationKey ? defaultValue : translation; + }; + + if (!failures) { + return null; + } + + const failureLine = (failure: HealthCheckFailure) => { + const summary = translationOrDefault(`healthCheckFailures.${failure.id}.summary`, failure.summary); + const description = translationOrDefault(`healthCheckFailures.${failure.id}.description`, failure.description); + + return ( +
  • + {summary} +
    + {description} +
    + {failure.url && ( + + {t("healthCheckFailures.detailUrl")} + + )} +
  • + ); + }; + + const failureComponents = failures.map(failureLine); + + return
      {failureComponents}
    ; +}; + +export default HealthCheckFailureList; diff --git a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx index b0661e23b6..8cdf25a0c9 100644 --- a/scm-ui/ui-components/src/repos/RepositoryEntry.tsx +++ b/scm-ui/ui-components/src/repos/RepositoryEntry.tsx @@ -29,6 +29,7 @@ import RepositoryAvatar from "./RepositoryAvatar"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { withTranslation, WithTranslation } from "react-i18next"; import styled from "styled-components"; +import HealthCheckFailureDetail from "./HealthCheckFailureDetail"; type DateProp = Date | string; @@ -39,6 +40,10 @@ type Props = WithTranslation & { baseDate?: DateProp; }; +type State = { + showHealthCheck: boolean; +}; + const RepositoryTag = styled.span` margin-left: 0.2rem; background-color: #9a9a9a; @@ -50,8 +55,26 @@ const RepositoryTag = styled.span` font-weight: bold; font-size: 0.7rem; `; +const RepositoryWarnTag = styled.span` + margin-left: 0.2rem; + background-color: #f14668; + padding: 0.25rem; + border-radius: 5px; + color: white; + overflow: visible; + pointer-events: all; + font-weight: bold; + font-size: 0.7rem; + cursor: help; +`; -class RepositoryEntry extends React.Component { +class RepositoryEntry extends React.Component { + constructor(props: Props) { + super(props); + this.state = { + showHealthCheck: false + }; + } createLink = (repository: Repository) => { return `/repo/${repository.namespace}/${repository.name}`; }; @@ -154,6 +177,19 @@ class RepositoryEntry extends React.Component { repositoryFlags.push({t("repository.exporting")}); } + if (repository.healthCheckFailures && repository.healthCheckFailures.length > 0) { + repositoryFlags.push( + { + this.setState({ showHealthCheck: true }); + }} + > + {t("repository.healthCheckFailure")} + + ); + } + return ( <> @@ -168,16 +204,27 @@ class RepositoryEntry extends React.Component { const footerLeft = this.createFooterLeft(repository, repositoryLink); const footerRight = this.createFooterRight(repository, baseDate); const title = this.createTitle(); - return ( - } - title={title} - description={repository.description} - link={repositoryLink} - footerLeft={footerLeft} - footerRight={footerRight} + const modal = ( + this.setState({ showHealthCheck: false })} + active={this.state.showHealthCheck} + failures={repository.healthCheckFailures} /> ); + + return ( + <> + {modal} + } + title={title} + description={repository.description} + link={repositoryLink} + footerLeft={footerLeft} + footerRight={footerRight} + /> + + ); } } diff --git a/scm-ui/ui-components/src/repos/index.ts b/scm-ui/ui-components/src/repos/index.ts index d7a444d2de..f5795e77cb 100644 --- a/scm-ui/ui-components/src/repos/index.ts +++ b/scm-ui/ui-components/src/repos/index.ts @@ -49,6 +49,7 @@ export { default as RepositoryEntry } from "./RepositoryEntry"; export { default as RepositoryEntryLink } from "./RepositoryEntryLink"; export { default as JumpToFileButton } from "./JumpToFileButton"; export { default as CommitAuthor } from "./CommitAuthor"; +export { default as HealthCheckFailureDetail } from "./HealthCheckFailureDetail"; export { File, diff --git a/scm-ui/ui-types/src/Repositories.ts b/scm-ui/ui-types/src/Repositories.ts index 93cd18ed18..2985350977 100644 --- a/scm-ui/ui-types/src/Repositories.ts +++ b/scm-ui/ui-types/src/Repositories.ts @@ -29,6 +29,13 @@ export type NamespaceAndName = { name: string; }; +export type HealthCheckFailure = { + id: string; + description: string; + summary: string; + url: string; +}; + export type RepositoryBase = NamespaceAndName & { type: string; contact?: string; @@ -41,6 +48,8 @@ export type Repository = HalRepresentation & lastModified?: string; archived?: boolean; exporting?: boolean; + healthCheckFailures?: HealthCheckFailure[]; + healthCheckRunning?: boolean; }; export type RepositoryCreation = RepositoryBase & { diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 091cfa1d14..ef26670e0b 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -8,7 +8,8 @@ "creationDate": "Erstellt", "lastModified": "Zuletzt bearbeitet", "archived": "archiviert", - "exporting": "Wird exportiert" + "exporting": "Wird exportiert", + "healthCheckFailure": "fehlerhaft" }, "validation": { "namespace-invalid": "Der Namespace des Repository ist ungültig", @@ -263,7 +264,11 @@ "initializeRepository": "Repository initiieren", "dangerZone": "Umbenennen, Archivieren und Löschen", "createButton": "Neues Repository erstellen", - "importButton": "Repository importieren" + "importButton": "Repository importieren", + "healthCheckWarning": { + "title": "Die letzte Integritätsprüfung dieses Repositories hat Fehler festgestellt. Für weitere Details hier klicken.", + "subtitle": "Um die Integritätsprüfung erneut auszuführen, klicken Sie den Schalter unter \"Integritätsprüfung starten\" weiter unten." + } }, "export": { "subtitle": "Repository exportieren", @@ -435,6 +440,17 @@ "exporting": { "tooltip": "Nur lesender Zugriff möglich. Das Repository wird derzeit exportiert." }, + "healthCheckFailure": { + "tooltip": "Dieses Repository ist fehlerhaft. Für weitere Details bitte klicken.", + "title": "Fehler im Repository", + "close": "Schließen" + }, + "runHealthCheck": { + "button": "Integritätsprüfung starten", + "subtitle": "Integritätsprüfung", + "descriptionNotRunning": "Starten der Integritätsprüfung dieses Repositories. Dieser Vorgang kann einige Zeit in Anspruch nehmen.", + "descriptionRunning": "Die Integritätsprüfung für dieses Repository läuft bereits und kann nicht parallel erneut gestartet werden." + }, "diff": { "jumpToSource": "Zur Quelldatei springen", "jumpToTarget": "Zur vorherigen Version der Datei springen", diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index 9a46680807..dd4e3ce9f7 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -8,7 +8,8 @@ "creationDate": "Creation Date", "lastModified": "Last Modified", "archived": "archived", - "exporting": "exporting" + "exporting": "exporting", + "healthCheckFailure": "erroneous" }, "validation": { "namespace-invalid": "The repository namespace is invalid", @@ -263,7 +264,11 @@ "initializeRepository": "Initialize Repository", "dangerZone": "Rename, Archive and Delete", "createButton": "Create Repository", - "importButton": "Import Repository" + "importButton": "Import Repository", + "healthCheckWarning": { + "title": "The latest health check for this repository reported failures. Click here for more details.", + "subtitle": "To rerun the health check, click the button in the \"Health Check\" part blow." + } }, "export": { "subtitle": "Repository Export", @@ -429,12 +434,23 @@ "cancel": "No" } }, + "runHealthCheck": { + "button": "Run Health Checks", + "subtitle": "Health Checks", + "descriptionNotRunning": "Run the health checks for this repository. This may take a while.", + "descriptionRunning": "Health checks for this repository are currently running and cannot be started again in parallel." + }, "archive": { "tooltip": "Read only. The archive cannot be changed." }, "exporting": { "tooltip": "Read only. The repository is currently being exported." }, + "healthCheckFailure": { + "tooltip": "This repository has health check failures. Click to get details.", + "title": "Health Check Failures", + "close": "Close" + }, "diff": { "changes": { "add": "added", diff --git a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx index d3fff64834..9b0cd001ed 100644 --- a/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/EditRepo.tsx @@ -25,12 +25,14 @@ import React, { FC } from "react"; import { Redirect, useRouteMatch } from "react-router-dom"; import RepositoryForm from "../components/form"; import { Repository } from "@scm-manager/ui-types"; -import { ErrorNotification, Subtitle, urls } from "@scm-manager/ui-components"; +import { ErrorNotification, Notification, Subtitle, urls } from "@scm-manager/ui-components"; import { ExtensionPoint } from "@scm-manager/ui-extensions"; import RepositoryDangerZone from "./RepositoryDangerZone"; import { useTranslation } from "react-i18next"; import ExportRepository from "./ExportRepository"; import { useIndexLinks, useUpdateRepository } from "@scm-manager/ui-api"; +import HealthCheckWarning from "./HealthCheckWarning"; +import RunHealthCheck from "./RunHealthCheck"; type Props = { repository: Repository; @@ -54,12 +56,16 @@ const EditRepo: FC = ({ repository }) => { return ( <> + - + {repository._links.exportInfo && } + {(repository._links.runHealthCheck || repository.healthCheckRunning) && ( + + )} ); diff --git a/scm-ui/ui-webapp/src/repos/containers/HealthCheckWarning.tsx b/scm-ui/ui-webapp/src/repos/containers/HealthCheckWarning.tsx new file mode 100644 index 0000000000..ab72338dd7 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/containers/HealthCheckWarning.tsx @@ -0,0 +1,62 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC, useState } from "react"; +import { Notification } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { Repository } from "@scm-manager/ui-types"; +import { HealthCheckFailureDetail } from "@scm-manager/ui-components"; + +type Props = { + repository: Repository; +}; + +const HealthCheckWarning: FC = ({ repository }) => { + const [t] = useTranslation("repos"); + const [showHealthCheck, setShowHealthCheck] = useState(false); + + if (repository.healthCheckFailures?.length === 0) { + return null; + } + + const modal = ( + setShowHealthCheck(false)} + active={showHealthCheck} + failures={repository.healthCheckFailures} + /> + ); + + return ( + + {modal} +
    setShowHealthCheck(true)}> +
    {t("repositoryForm.healthCheckWarning.title")}
    +
    {t("repositoryForm.healthCheckWarning.subtitle")}
    +
    +
    + ); +}; + +export default HealthCheckWarning; diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx index 9193456227..452fe5d4ba 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryDangerZone.tsx @@ -76,7 +76,6 @@ const RepositoryDangerZone: FC = ({ repository, indexLinks }) => { if (repository?._links?.unarchive) { dangerZone.push(); } - if (dangerZone.length === 0) { return null; } diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx index 090636b1cb..30db024b58 100644 --- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React from "react"; +import React, { useState } from "react"; import { Redirect, Route, Link as RouteLink, Switch, useRouteMatch } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; @@ -30,6 +30,7 @@ import { CustomQueryFlexWrappedColumns, ErrorPage, FileControlFactory, + HealthCheckFailureDetail, JumpToFileButton, Loading, NavLink, @@ -68,6 +69,15 @@ const RepositoryTag = styled.span` font-weight: bold; `; +const RepositoryWarnTag = styled.span` + margin-left: 0.2rem; + background-color: #f14668; + padding: 0.4rem; + border-radius: 5px; + color: white; + font-weight: bold; +`; + type UrlParams = { namespace: string; name: string; @@ -86,6 +96,7 @@ const RepositoryRoot = () => { const match = useRouteMatch(); const { isLoading, error, repository } = useRepositoryFromUrl(match); const indexLinks = useIndexLinks(); + const [showHealthCheck, setShowHealthCheck] = useState(false); const [t] = useTranslation("repos"); @@ -174,6 +185,16 @@ const RepositoryRoot = () => { ); } + if (repository.healthCheckFailures && repository.healthCheckFailures.length > 0) { + repositoryFlags.push( + + setShowHealthCheck(true)}> + {t("repository.healthCheckFailure")} + + + ); + } + const titleComponent = ( <> @@ -221,6 +242,14 @@ const RepositoryRoot = () => { return `${url}/code/changesets`; }; + const modal = ( + setShowHealthCheck(false)} + active={showHealthCheck} + failures={repository.healthCheckFailures} + /> + ); + return ( { } > + {modal} diff --git a/scm-ui/ui-webapp/src/repos/containers/RunHealthCheck.tsx b/scm-ui/ui-webapp/src/repos/containers/RunHealthCheck.tsx new file mode 100644 index 0000000000..238a3d0d59 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/containers/RunHealthCheck.tsx @@ -0,0 +1,72 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import React, { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Repository } from "@scm-manager/ui-types"; +import { Button, ErrorNotification, Level, Subtitle } from "@scm-manager/ui-components"; +import { useRunHealthCheck } from "@scm-manager/ui-api"; +import styled from "styled-components"; + +type Props = { + repository: Repository; +}; + +const MarginTopButton = styled(Button)` + margin-top: 1rem; +`; + +const RunHealthCheck: FC = ({ repository }) => { + const { isLoading, error, runHealthCheck } = useRunHealthCheck(); + const [t] = useTranslation("repos"); + + const runHealthCheckCallback = () => { + runHealthCheck(repository); + }; + + return ( + <> +
    + + {t("runHealthCheck.subtitle")} +

    + {repository.healthCheckRunning + ? t("runHealthCheck.descriptionRunning") + : t("runHealthCheck.descriptionNotRunning")} +

    + + } + /> + + ); +}; + +export default RunHealthCheck; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java index a3bb2dd14b..7238f64baa 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/HealthCheckFailureDto.java @@ -21,14 +21,25 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.api.v2.resources; +import de.otto.edison.hal.HalRepresentation; +import de.otto.edison.hal.Links; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; -@Getter @Setter -public class HealthCheckFailureDto { +@Getter +@Setter +@NoArgsConstructor +@SuppressWarnings("java:S2160") // we do not need this for dto +public class HealthCheckFailureDto extends HalRepresentation { + public HealthCheckFailureDto(Links links) { + super(links); + } + + private String id; private String description; private String summary; private String url; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java index cc5ca973ea..9dee059407 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryDto.java @@ -59,6 +59,7 @@ public class RepositoryDto extends HalRepresentation implements CreateRepository private String type; private boolean archived; private boolean exporting; + private boolean healthCheckRunning; RepositoryDto(Links links, Embedded embedded) { super(links, embedded); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java index 80f796b82f..937b5b4021 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryResource.java @@ -30,6 +30,7 @@ import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import sonia.scm.repository.HealthCheckService; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryManager; @@ -61,17 +62,19 @@ public class RepositoryResource { private final RepositoryManager manager; private final SingleResourceManagerAdapter adapter; private final RepositoryBasedResourceProvider resourceProvider; + private final HealthCheckService healthCheckService; @Inject public RepositoryResource( RepositoryToRepositoryDtoMapper repositoryToDtoMapper, RepositoryDtoToRepositoryMapper dtoToRepositoryMapper, RepositoryManager manager, - RepositoryBasedResourceProvider resourceProvider) { + RepositoryBasedResourceProvider resourceProvider, HealthCheckService healthCheckService) { this.dtoToRepositoryMapper = dtoToRepositoryMapper; this.manager = manager; this.repositoryToDtoMapper = repositoryToDtoMapper; this.adapter = new SingleResourceManagerAdapter<>(manager, Repository.class); this.resourceProvider = resourceProvider; + this.healthCheckService = healthCheckService; } /** @@ -271,6 +274,25 @@ public class RepositoryResource { manager.unarchive(repository); } + @POST + @Path("runHealthCheck") + @Operation(summary = "Check health of repository", description = "Starts a full health check for the repository.", tags = "Repository") + @ApiResponse(responseCode = "204", description = "check started") + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"repository:healthCheck\" privilege") + @ApiResponse( + responseCode = "404", + description = "not found, no repository with the specified namespace and name available", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + @ApiResponse(responseCode = "500", description = "internal server error") + public void runHealthCheck(@PathParam("namespace") String namespace, @PathParam("name") String name) { + Repository repository = loadBy(namespace, name).get(); + healthCheckService.fullCheck(repository); + } + private Repository processUpdate(RepositoryDto repositoryDto, Repository existing) { Repository changedRepository = dtoToRepositoryMapper.map(repositoryDto, existing.getId()); changedRepository.setPermissions(existing.getPermissions()); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java index 711ae524c5..f28736ec21 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapper.java @@ -32,10 +32,12 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.MappingTarget; import org.mapstruct.ObjectFactory; +import sonia.scm.SCMContextProvider; import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.DefaultRepositoryExportingCheck; import sonia.scm.repository.Feature; import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.HealthCheckService; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryPermissions; @@ -68,9 +70,28 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper strategies; + @Inject + private HealthCheckService healthCheckService; + @Inject + private SCMContextProvider contextProvider; abstract HealthCheckFailureDto toDto(HealthCheckFailure failure); + @ObjectFactory + HealthCheckFailureDto createHealthCheckFailureDto(HealthCheckFailure failure) { + String url = failure.getUrl(contextProvider.getDocumentationVersion()); + if (url == null) { + return new HealthCheckFailureDto(); + } else { + return new HealthCheckFailureDto(Links.linkingTo().single(link("documentation", url)).build()); + } + } + + @AfterMapping + void updateHealthCheckUrlForCurrentVersion(HealthCheckFailure failure, @MappingTarget HealthCheckFailureDto dto) { + dto.setUrl(failure.getUrl(contextProvider.getDocumentationVersion())); + } + @Mapping(target = "attributes", ignore = true) // We do not map HAL attributes @Mapping(target = "exporting", ignore = true) @Override @@ -138,11 +159,16 @@ public abstract class RepositoryToRepositoryDtoMapper extends BaseMapper void bind(Class clazz, Class defaultImplementation) { diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultHealthCheckService.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultHealthCheckService.java new file mode 100644 index 0000000000..8c68ea3a47 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultHealthCheckService.java @@ -0,0 +1,59 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import javax.inject.Inject; + +public class DefaultHealthCheckService implements HealthCheckService { + + private final HealthChecker healthChecker; + + @Inject + public DefaultHealthCheckService(HealthChecker healthChecker) { + this.healthChecker = healthChecker; + } + + @Override + public void fullCheck(String id) { + RepositoryPermissions.healthCheck(id).check(); + healthChecker.fullCheck(id); + } + + @Override + public void fullCheck(Repository repository) { + RepositoryPermissions.healthCheck(repository).check(); + healthChecker.fullCheck(repository); + } + + @Override + public boolean checkRunning(String repositoryId) { + return healthChecker.checkRunning(repositoryId); + } + + @Override + public boolean checkRunning(Repository repository) { + return healthChecker.checkRunning(repository.getId()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java index 73e3ea03e3..4564f5d36c 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/DefaultRepositoryManager.java @@ -77,14 +77,16 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { private final Set types; private final Provider namespaceStrategyProvider; private final ManagerDaoAdapter managerDaoAdapter; + private final RepositoryPostProcessor repositoryPostProcessor; @Inject public DefaultRepositoryManager(SCMContextProvider contextProvider, KeyGenerator keyGenerator, RepositoryDAO repositoryDAO, Set handlerSet, - Provider namespaceStrategyProvider) { + Provider namespaceStrategyProvider, RepositoryPostProcessor repositoryPostProcessor) { this.keyGenerator = keyGenerator; this.repositoryDAO = repositoryDAO; this.namespaceStrategyProvider = namespaceStrategyProvider; + this.repositoryPostProcessor = repositoryPostProcessor; handlerMap = new HashMap<>(); types = new HashSet<>(); @@ -220,7 +222,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { Repository repository = repositoryDAO.get(id); if (repository != null) { - repository = repository.clone(); + repository = postProcess(repository); } return repository; @@ -236,7 +238,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { if (repository != null) { RepositoryPermissions.read(repository).check(); - repository = repository.clone(); + repository = postProcess(repository); } return repository; @@ -289,7 +291,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { throw new NoChangesMadeException(repository); } - Repository changedRepository = originalRepository.clone(); + Repository changedRepository = postProcess(originalRepository); changedRepository.setArchived(archived); @@ -314,7 +316,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { if (handlerMap.containsKey(repository.getType()) && filter.test(repository) && RepositoryPermissions.read().isPermitted(repository)) { - Repository r = repository.clone(); + Repository r = postProcess(repository); repositories.add(r); } @@ -342,7 +344,7 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { @Override public void append(Collection collection, Repository item) { if (RepositoryPermissions.read().isPermitted(item)) { - collection.add(item.clone()); + collection.add(postProcess(item)); } } }, start, limit); @@ -427,4 +429,10 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager { return handler; } + + private Repository postProcess(Repository repository) { + Repository clone = repository.clone(); + repositoryPostProcessor.postProcess(repository); + return clone; + } } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckContextListener.java b/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckContextListener.java index 8a518a1364..03f37f5d23 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckContextListener.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckContextListener.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.repository; //~--- non-JDK imports -------------------------------------------------------- @@ -121,7 +121,7 @@ public class HealthCheckContextListener implements ServletContextListener { // excute health checks for all repsitories asynchronous - SecurityUtils.getSubject().execute(healthChecker::checkAll); + SecurityUtils.getSubject().execute(healthChecker::lightCheckAll); } //~--- fields ------------------------------------------------------------- diff --git a/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckService.java b/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckService.java new file mode 100644 index 0000000000..07437096af --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/HealthCheckService.java @@ -0,0 +1,36 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +public interface HealthCheckService { + + void fullCheck(String id); + + void fullCheck(Repository repository); + + boolean checkRunning(String repositoryId); + + boolean checkRunning(Repository repository); +} diff --git a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java index 875132891b..24339f0fbd 100644 --- a/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java +++ b/scm-webapp/src/main/java/sonia/scm/repository/HealthChecker.java @@ -24,17 +24,27 @@ package sonia.scm.repository; -import com.github.sdorra.ssp.PermissionActionCheck; -import com.google.common.collect.ImmutableList; import com.google.inject.Inject; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.NotFoundException; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import javax.inject.Singleton; +import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import static java.util.Collections.synchronizedCollection; -public final class HealthChecker { +@Singleton +final class HealthChecker { private static final Logger logger = LoggerFactory.getLogger(HealthChecker.class); @@ -42,40 +52,68 @@ public final class HealthChecker { private final Set checks; private final RepositoryManager repositoryManager; + private final RepositoryServiceFactory repositoryServiceFactory; + private final RepositoryPostProcessor repositoryPostProcessor; + + private final Collection checksRunning = synchronizedCollection(new HashSet<>()); + + private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor(); @Inject - public HealthChecker(Set checks, - RepositoryManager repositoryManager) { + HealthChecker(Set checks, + RepositoryManager repositoryManager, + RepositoryServiceFactory repositoryServiceFactory, + RepositoryPostProcessor repositoryPostProcessor) { this.checks = checks; this.repositoryManager = repositoryManager; + this.repositoryServiceFactory = repositoryServiceFactory; + this.repositoryPostProcessor = repositoryPostProcessor; } - public void check(String id){ + void lightCheck(String id) { RepositoryPermissions.healthCheck(id).check(); + Repository repository = loadRepository(id); + + doLightCheck(repository); + } + + void fullCheck(String id) { + RepositoryPermissions.healthCheck(id).check(); + + Repository repository = loadRepository(id); + + doFullCheck(repository); + } + + void lightCheck(Repository repository) { + RepositoryPermissions.healthCheck(repository).check(); + + doLightCheck(repository); + } + + void fullCheck(Repository repository) { + RepositoryPermissions.healthCheck(repository).check(); + + doFullCheck(repository); + } + + private Repository loadRepository(String id) { Repository repository = repositoryManager.get(id); if (repository == null) { throw new NotFoundException(Repository.class, id); } - - doCheck(repository); + return repository; } - public void check(Repository repository) - { - RepositoryPermissions.healthCheck(repository).check(); - - doCheck(repository); - } - - public void checkAll() { + void lightCheckAll() { logger.debug("check health of all repositories"); for (Repository repository : repositoryManager.getAll()) { if (RepositoryPermissions.healthCheck().isPermitted(repository)) { try { - check(repository); + lightCheck(repository); } catch (NotFoundException ex) { logger.error("health check ends with exception", ex); } @@ -87,32 +125,89 @@ public final class HealthChecker { } } - private void doCheck(Repository repository){ - logger.info("start health check for repository {}", repository); + private void doLightCheck(Repository repository) { + withLockedRepository(repository, () -> { + HealthCheckResult result = gatherLightChecks(repository); + + if (result.isUnhealthy()) { + logger.warn("repository {} is unhealthy: {}", repository, + result); + } else { + logger.info("repository {} is healthy", repository); + } + + storeResult(repository, result); + }); + } + + private HealthCheckResult gatherLightChecks(Repository repository) { + logger.info("start light health check for repository {}", repository); HealthCheckResult result = HealthCheckResult.healthy(); for (HealthCheck check : checks) { - logger.trace("execute health check {} for repository {}", + logger.trace("execute light health check {} for repository {}", check.getClass(), repository); result = result.merge(check.check(repository)); } + return result; + } - if (result.isUnhealthy()) { - logger.warn("repository {} is unhealthy: {}", repository, - result); - } else { - logger.info("repository {} is healthy", repository); + private void doFullCheck(Repository repository) { + withLockedRepository(repository, () -> + runInExecutorAndWait(repository, () -> { + HealthCheckResult lightCheckResult = gatherLightChecks(repository); + HealthCheckResult fullCheckResult = gatherFullChecks(repository); + HealthCheckResult result = lightCheckResult.merge(fullCheckResult); + + storeResult(repository, result); + }) + ); + } + + private void withLockedRepository(Repository repository, Runnable runnable) { + if (!checksRunning.add(repository.getId())) { + logger.debug("check for repository {} is already running", repository); + return; } - - if (!(repository.isHealthy() && result.isHealthy())) { - logger.trace("store health check results for repository {}", - repository); - repository.setHealthCheckFailures( - ImmutableList.copyOf(result.getFailures())); - repositoryManager.modify(repository); + try { + runnable.run(); + } finally { + checksRunning.remove(repository.getId()); } } + private void runInExecutorAndWait(Repository repository, Runnable runnable) { + try { + healthCheckExecutor.submit(runnable).get(); + } catch (ExecutionException e) { + logger.warn("could not submit task for health check for repository {}", repository, e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + private HealthCheckResult gatherFullChecks(Repository repository) { + try (RepositoryService service = repositoryServiceFactory.create(repository)) { + if (service.isSupported(Command.FULL_HEALTH_CHECK)) { + return service.getFullCheckCommand().check(); + } else { + return HealthCheckResult.healthy(); + } + } catch (IOException e) { + throw new InternalRepositoryException(repository, "error during full health check", e); + } + } + + private void storeResult(Repository repository, HealthCheckResult result) { + if (!(repository.isHealthy() && result.isHealthy())) { + logger.trace("store health check results for repository {}", + repository); + repositoryPostProcessor.setCheckResults(repository, result.getFailures()); + } + } + + public boolean checkRunning(String repositoryId) { + return checksRunning.contains(repositoryId); + } } diff --git a/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPostProcessor.java b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPostProcessor.java new file mode 100644 index 0000000000..9863bcfdcf --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/repository/RepositoryPostProcessor.java @@ -0,0 +1,66 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import sonia.scm.event.ScmEventBus; + +import javax.inject.Inject; +import javax.inject.Singleton; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.google.common.collect.ImmutableList.copyOf; +import static java.util.Collections.emptyList; + +@Singleton +class RepositoryPostProcessor { + + private final ScmEventBus eventBus; + + private final Map> checkResults = new HashMap<>(); + + @Inject + RepositoryPostProcessor(ScmEventBus eventBus) { + this.eventBus = eventBus; + } + + void setCheckResults(Repository repository, Collection failures) { + List oldFailures = getCheckResults(repository.getId()); + List copyOfFailures = copyOf(failures); + checkResults.put(repository.getId(), copyOfFailures); + repository.setHealthCheckFailures(copyOfFailures); + eventBus.post(new HealthCheckEvent(repository, oldFailures, copyOfFailures)); + } + + void postProcess(Repository repository) { + repository.setHealthCheckFailures(getCheckResults(repository.getId())); + } + + private List getCheckResults(String repositoryId) { + return checkResults.getOrDefault(repositoryId, emptyList()); + } +} diff --git a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml index 35d1a49181..10b745b68d 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -38,6 +38,9 @@ repository:read,rename:* + + repository:read,healthCheck:* + repository:* diff --git a/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml index 2f5c48b8d2..db2b6da81c 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/repository-permissions.xml @@ -35,6 +35,7 @@ permissionWrite archive export + healthCheck * diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 6409798dd7..82b7ff518f 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -53,6 +53,12 @@ "displayName": "Repositories exportieren", "description": "Achtung: Darf alle Repositories inkl. aller Metadaten exportieren." } + }, + "read,healthCheck": { + "*": { + "displayName": "Integritätsprüfungen ausführen", + "description": "Darf alle Repositories sehen und Integritätsprüfungen starten." + } } }, "user": { @@ -165,6 +171,10 @@ "displayName": "Repository exportieren", "description": "Achtung: Darf das Repository inkl. aller Metadaten exportieren." }, + "healthCheck": { + "displayName": "überprüfen", + "description": "Darf das Repository überprüfen." + }, "*": { "displayName": "Alle Repository Rechte", "description": "Darf im Repository Kontext alles ausführen. Dies beinhaltet alle Repository Berechtigungen." @@ -350,6 +360,65 @@ "description": "Der Import ist für den gegebenen Repository-Typen nicht möglich." } }, + "healthCheckFailures": { + "detailUrl": "Hier finden sich weitere Informationen.", + "AnOTx99ex1": { + "summary": "Inkompatibles DB Format", + "description": "Das Subversion DB Format ist inkompatibel mit der SVN Version, die im SCM-Manager genutzt wird." + }, + "4IOTx8pvv1": { + "summary": "DB/Format Datei kann nicht gelesen werden", + "description": "Die DB/Format Datei des Repositories konnte nicht gelesen werden." + }, + "6TOTx9RLD1": { + "summary": "Das Repository lässt sich nicht öffnen", + "description": "Das SVN Repository kann nicht geöffnet werden." + }, + "A9OTx8leC1": { + "summary": "DB/Format Datei kann nicht gefunden werden", + "description": "Das Subversion Repository enthält keine DB/Format Datei." + }, + "2OOTx6ta71": { + "summary": "Repository hat keinen Typen", + "description": "Das Repository hat keinen konfigurierten Typ." + }, + "CqOTx7Jkq1": { + "summary": "Kein Handler für Repository Typ", + "description": "Es ist kein Handler für den Typen des Repositories registriert." + }, + "AcOTx7fD51": { + "summary": "Handler kann Verzeichnis nicht zurückgeben", + "description": "Der Handler war nicht in der Lage, ein Verzeichnis für das Repository zurückzugeben." + }, + "1oOTx803F1": { + "summary": "Repository Verzeichnis existiert nicht", + "description": "Das Repository existiert nicht. Eventuell wurde es außerhalb des SCM-Managers gelöscht." + }, + "AKOdhQ0pw1": { + "summary": "Kein .git oder refs Verzeichnis gefunden", + "description": "Das Git-Repository enthält weder ein '.git' noch ein 'refs' Verzeichnis" + }, + "FaSUYbZUR1": { + "summary": "'hg verify' fehlgeschlagen", + "description": "Die Prüfung 'hg verify' ist für das Repository fehlgeschlagen." + }, + "6bOdhOXpB1": { + "summary": "Verzeichnis .hg nicht gefunden", + "description": "Das Mercurial Repository enthält kein .hg Verzeichnis." + }, + "9cSV1eaVF1": { + "summary": "Repository Verzeichnis nicht schreibbar", + "description": "Der Systemuser hat keine Berechtigung, Dateien im Verzeichnis für das Repository zu erstellen oder zu löschen." + }, + "6bSUg4dZ41": { + "summary": "Metadaten Datei nicht schreibbar", + "description": "Die Datei für die Metadaten des Repositories kann vom Systemuser nicht geschrieben werden." + }, + "5FSV2kreE1": { + "summary": "'svn verify' fehlgeschlagen", + "description": "Die Prüfung 'svn verify' ist für das Repository fehlgeschlagen." + } + }, "namespaceStrategies": { "UsernameNamespaceStrategy": "Benutzername", "CustomNamespaceStrategy": "Benutzerdefiniert", diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 00f778784e..31274bb97e 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -53,6 +53,12 @@ "displayName": "Export repositories", "description": "Attention: May export all repositories including all metadata" } + }, + "read,healthCheck": { + "*": { + "displayName": "Run repository health checks", + "description": "May see all repositories and run health checks for them" + } } }, "user": { @@ -165,6 +171,10 @@ "displayName": "export repository", "description": "Attention: May export the repository including all metadata" }, + "healthCheck": { + "displayName": "run health checks", + "description": "May run health checks for this repository" + }, "*": { "displayName": "own repository", "description": "May change everything for the repository (includes all other permissions)" @@ -350,6 +360,9 @@ "description": "The import is not possible for the given repository type." } }, + "healthChecksFailures": { + "detailUrl": "Find more details here." + }, "namespaceStrategies": { "UsernameNamespaceStrategy": "Username", "CustomNamespaceStrategy": "Custom", diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index 09aa7f6ed8..46ef326b6f 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -55,6 +55,7 @@ import sonia.scm.importexport.FullScmRepositoryImporter; import sonia.scm.importexport.RepositoryImportExportEncryption; import sonia.scm.importexport.RepositoryImportLoggerFactory; import sonia.scm.repository.CustomNamespaceStrategy; +import sonia.scm.repository.HealthCheckService; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.Repository; @@ -157,6 +158,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { private RepositoryImportLoggerFactory importLoggerFactory; @Mock private ExportService exportService; + @Mock + private HealthCheckService healthCheckService; @Captor private ArgumentCaptor> filterCaptor; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java index 2b664bcd34..61d3d80bd6 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTestBase.java @@ -24,6 +24,7 @@ package sonia.scm.api.v2.resources; +import sonia.scm.repository.HealthCheckService; import sonia.scm.repository.RepositoryManager; import static com.google.inject.util.Providers.of; @@ -48,6 +49,7 @@ abstract class RepositoryTestBase { RepositoryImportResource repositoryImportResource; RepositoryExportResource repositoryExportResource; RepositoryPathsResource repositoryPathsResource; + HealthCheckService healthCheckService; RepositoryRootResource getRepositoryRootResource() { RepositoryBasedResourceProvider repositoryBasedResourceProvider = new RepositoryBasedResourceProvider( @@ -70,7 +72,9 @@ abstract class RepositoryTestBase { repositoryToDtoMapper, dtoToRepositoryMapper, manager, - repositoryBasedResourceProvider)), - of(repositoryCollectionResource), of(repositoryImportResource)); + repositoryBasedResourceProvider, + healthCheckService)), + of(repositoryCollectionResource), + of(repositoryImportResource)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java index 24fb96d84e..267fdcbdb4 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryToRepositoryDtoMapperTest.java @@ -34,9 +34,11 @@ import org.junit.Rule; import org.junit.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import sonia.scm.SCMContextProvider; import sonia.scm.config.ScmConfiguration; import sonia.scm.repository.CustomNamespaceStrategy; import sonia.scm.repository.HealthCheckFailure; +import sonia.scm.repository.HealthCheckService; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.Repository; import sonia.scm.repository.api.Command; @@ -57,6 +59,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.when; import static org.mockito.MockitoAnnotations.initMocks; +import static sonia.scm.repository.HealthCheckFailure.templated; @SubjectAware( username = "trillian", @@ -83,6 +86,10 @@ public class RepositoryToRepositoryDtoMapperTest { private ScmConfiguration configuration; @Mock private Set strategies; + @Mock + private HealthCheckService healthCheckService; + @Mock + private SCMContextProvider scmContextProvider; @InjectMocks private RepositoryToRepositoryDtoMapperImpl mapper; @@ -311,6 +318,62 @@ public class RepositoryToRepositoryDtoMapperTest { }); } + @Test + public void shouldCreateRunHealthCheckLink() { + RepositoryDto dto = mapper.map(createTestRepository()); + assertEquals( + "http://example.com/base/v2/repositories/testspace/test/runHealthCheck", + dto.getLinks().getLinkBy("runHealthCheck").get().getHref()); + assertFalse(dto.isHealthCheckRunning()); + } + + @Test + public void shouldNotCreateHealthCheckLinkIfCheckIsRunning() { + Repository testRepository = createTestRepository(); + when(healthCheckService.checkRunning(testRepository)).thenReturn(true); + RepositoryDto dto = mapper.map(testRepository); + assertFalse(dto.getLinks().getLinkBy("runHealthCheck").isPresent()); + assertTrue(dto.isHealthCheckRunning()); + } + + @Test + public void shouldCreateCorrectLinksForHealthChecks() { + when(scmContextProvider.getDocumentationVersion()).thenReturn("2.17.x"); + + Repository testRepository = createTestRepository(); + HealthCheckFailure failure = new HealthCheckFailure("1", "vogons", templated("http://hog/{0}/vogons"), "met vogons"); + testRepository.setHealthCheckFailures(singletonList(failure)); + + RepositoryDto dto = mapper.map(testRepository); + + assertThat(dto.getHealthCheckFailures()) + .extracting("url") + .containsExactly("http://hog/2.17.x/vogons"); + + assertThat(dto.getHealthCheckFailures().get(0).getLinks().getLinkBy("documentation")) + .get() + .extracting("href") + .isEqualTo("http://hog/2.17.x/vogons"); + } + + @Test + public void shouldCreateNoLinksForHealthChecksWithoutUrl() { + when(scmContextProvider.getDocumentationVersion()).thenReturn("2.17.x"); + + Repository testRepository = createTestRepository(); + HealthCheckFailure failure = new HealthCheckFailure("1", "vogons", "met vogons"); + testRepository.setHealthCheckFailures(singletonList(failure)); + + RepositoryDto dto = mapper.map(testRepository); + + assertThat(dto.getHealthCheckFailures()) + .extracting("url") + .containsExactly(new Object[] {null}); + + assertThat(dto.getHealthCheckFailures().get(0).getLinks().getLinkBy("documentation")) + .isNotPresent(); + } + private ScmProtocol mockProtocol(String type, String protocol) { return new MockScmProtocol(type, protocol); } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java index fda8030b1d..b2e0dde705 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerPerfTest.java @@ -102,6 +102,8 @@ public class DefaultRepositoryManagerPerfTest { @Mock private AuthorizationCollector authzCollector; + @Mock + private RepositoryPostProcessor repositoryPostProcessor; /** * Setup object under test. @@ -116,8 +118,8 @@ public class DefaultRepositoryManagerPerfTest { keyGenerator, repositoryDAO, handlerSet, - Providers.of(namespaceStrategy) - ); + Providers.of(namespaceStrategy), + repositoryPostProcessor); setUpTestRepositories(); diff --git a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java index 1b9479a7db..ba684b6733 100644 --- a/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/repository/DefaultRepositoryManagerTest.java @@ -106,6 +106,7 @@ import sonia.scm.TempSCMContextProvider; public class DefaultRepositoryManagerTest extends ManagerTestBase { private RepositoryDAO repositoryDAO; + private RepositoryPostProcessor postProcessor = mock(RepositoryPostProcessor.class); static { ThreadContext.unbindSubject(); @@ -181,6 +182,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase { heartOfGold = manager.get(id); assertNotNull(heartOfGold); assertEquals(description, heartOfGold.getDescription()); + verify(postProcessor).postProcess(argThat(repository -> repository.getId().equals(id))); } @Test @@ -227,6 +229,8 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase { assertNotSame(heartOfGold, heartReference); heartReference.setDescription("prototype ship"); assertNotEquals(heartOfGold.getDescription(), heartReference.getDescription()); + verify(postProcessor).postProcess(argThat(repository -> repository.getId().equals(heartOfGold.getId()))); + verify(postProcessor).postProcess(argThat(repository -> repository.getId().equals(happyVerticalPeopleTransporter.getId()))); } @Test @@ -551,7 +555,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase { when(namespaceStrategy.createNamespace(Mockito.any(Repository.class))).thenAnswer(invocation -> mockedNamespace); return new DefaultRepositoryManager(contextProvider, - keyGenerator, repositoryDAO, handlerSet, Providers.of(namespaceStrategy)); + keyGenerator, repositoryDAO, handlerSet, Providers.of(namespaceStrategy), postProcessor); } private RepositoryDAO createRepositoryDaoMock() { @@ -618,9 +622,6 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase { private Repository createRepository(Repository repository) { manager.create(repository); assertNotNull(repository.getId()); - assertNotNull(manager.get(repository.getId())); - assertTrue(repository.getCreationDate() > 0); - return repository; } diff --git a/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java new file mode 100644 index 0000000000..5736512752 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/HealthCheckerTest.java @@ -0,0 +1,278 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.NotFoundException; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.FullHealthCheckCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; + +import static com.google.common.collect.ImmutableSet.of; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HealthCheckerTest { + + private final Repository repository = RepositoryTestData.createHeartOfGold(); + private final String repositoryId = repository.getId(); + + @Mock + private HealthCheck healthCheck1; + @Mock + private HealthCheck healthCheck2; + + @Mock + private RepositoryManager repositoryManager; + @Mock + private RepositoryServiceFactory repositoryServiceFactory; + @Mock + private RepositoryService repositoryService; + @Mock + private RepositoryPostProcessor postProcessor; + + @Mock + private Subject subject; + + private HealthChecker checker; + + @BeforeEach + void initializeChecker() { + this.checker = new HealthChecker(of(healthCheck1, healthCheck2), repositoryManager, repositoryServiceFactory, postProcessor); + } + + @BeforeEach + void initSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void cleanupSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldFailForNotExistingRepositoryId() { + assertThrows(NotFoundException.class, () -> checker.lightCheck("no-such-id")); + } + + @Nested + class WithRepository { + @BeforeEach + void setUpRepository() { + doReturn(repository).when(repositoryManager).get(repositoryId); + } + + @Test + void shouldComputeLightChecks() { + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.unhealthy(createFailure("error1"))); + when(healthCheck2.check(repository)).thenReturn(HealthCheckResult.unhealthy(createFailure("error2"))); + + checker.lightCheck(repositoryId); + + verify(postProcessor).setCheckResults(eq(repository), argThat(failures -> { + assertThat(failures) + .hasSize(2) + .extracting("id").containsExactly("error1", "error2"); + return true; + })); + } + + @Test + void shouldLockWhileLightCheckIsRunning() throws InterruptedException { + CountDownLatch waitUntilSecondCheckHasRun = new CountDownLatch(1); + CountDownLatch waitForFirstCheckStarted = new CountDownLatch(1); + + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(healthCheck2.check(repository)).thenAnswer(invocation -> { + waitForFirstCheckStarted.countDown(); + waitUntilSecondCheckHasRun.await(); + return HealthCheckResult.healthy(); + }); + + new Thread(() -> checker.lightCheck(repositoryId)).start(); + + waitForFirstCheckStarted.await(); + await().until(() -> { + checker.lightCheck(repositoryId); + return true; + }); + + waitUntilSecondCheckHasRun.countDown(); + + verify(healthCheck1).check(repository); + } + + @Test + void shouldShowRunningCheck() throws InterruptedException { + CountDownLatch waitUntilVerification = new CountDownLatch(1); + CountDownLatch waitForFirstCheckStarted = new CountDownLatch(1); + + assertThat(checker.checkRunning(repositoryId)).isFalse(); + + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(healthCheck2.check(repository)).thenAnswer(invocation -> { + waitForFirstCheckStarted.countDown(); + waitUntilVerification.await(); + return HealthCheckResult.healthy(); + }); + + new Thread(() -> checker.lightCheck(repositoryId)).start(); + + waitForFirstCheckStarted.await(); + + assertThat(checker.checkRunning(repositoryId)).isTrue(); + + waitUntilVerification.countDown(); + + await().until(() -> !checker.checkRunning(repositoryId)); + } + + @Nested + class ForFullChecks { + + @Mock + private FullHealthCheckCommandBuilder fullHealthCheckCommand; + + @BeforeEach + void setUpRepository() { + when(repositoryServiceFactory.create(repository)).thenReturn(repositoryService); + lenient().when(repositoryService.getFullCheckCommand()).thenReturn(fullHealthCheckCommand); + } + + @Test + void shouldComputeLightChecksForFullChecks() { + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(healthCheck2.check(repository)).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(postProcessor).setCheckResults(eq(repository), argThat(failures -> { + assertThat(failures) + .hasSize(1) + .extracting("id").containsExactly("error"); + return true; + })); + } + + @Test + void shouldLockWhileFullCheckIsRunning() throws InterruptedException { + CountDownLatch waitUntilSecondCheckHasRun = new CountDownLatch(1); + CountDownLatch waitForFirstCheckStarted = new CountDownLatch(1); + + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(healthCheck2.check(repository)).thenAnswer(invocation -> { + waitForFirstCheckStarted.countDown(); + waitUntilSecondCheckHasRun.await(); + return HealthCheckResult.healthy(); + }); + + new Thread(() -> checker.fullCheck(repositoryId)).start(); + + waitForFirstCheckStarted.await(); + await().until(() -> { + checker.fullCheck(repositoryId); + return true; + }); + + waitUntilSecondCheckHasRun.countDown(); + + verify(healthCheck1).check(repository); + } + + @Test + void shouldComputeFullChecks() throws IOException { + when(healthCheck1.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(healthCheck2.check(repository)).thenReturn(HealthCheckResult.healthy()); + when(repositoryService.isSupported(Command.FULL_HEALTH_CHECK)).thenReturn(true); + when(fullHealthCheckCommand.check()).thenReturn(HealthCheckResult.unhealthy(createFailure("error"))); + + checker.fullCheck(repositoryId); + + verify(postProcessor).setCheckResults(eq(repository), argThat(failures -> { + assertThat(failures) + .hasSize(1) + .extracting("id").containsExactly("error"); + return true; + })); + } + } + } + + @Nested + class WithoutPermission { + + @BeforeEach + void setMissingPermission() { + doThrow(AuthorizationException.class).when(subject).checkPermission("repository:healthCheck:" + repositoryId); + } + + @Test + void shouldFailToRunLightChecksWithoutPermissionForId() { + assertThrows(AuthorizationException.class, () -> checker.lightCheck(repositoryId)); + } + + @Test + void shouldFailToRunLightChecksWithoutPermissionForRepository() { + assertThrows(AuthorizationException.class, () -> checker.lightCheck(repository)); + } + + @Test + void shouldFailToRunFullChecksWithoutPermissionForId() { + assertThrows(AuthorizationException.class, () -> checker.fullCheck(repositoryId)); + } + + @Test + void shouldFailToRunFullChecksWithoutPermissionForRepository() { + assertThrows(AuthorizationException.class, () -> checker.fullCheck(repository)); + } + } + + private HealthCheckFailure createFailure(String text) { + return new HealthCheckFailure(text, text, text); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPostProcessorTest.java b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPostProcessorTest.java new file mode 100644 index 0000000000..b574696616 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/repository/RepositoryPostProcessorTest.java @@ -0,0 +1,121 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.repository; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.event.ScmEventBus; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class RepositoryPostProcessorTest { + + @Mock + private ScmEventBus eventBus; + + @InjectMocks + RepositoryPostProcessor repositoryPostProcessor; + + @Test + void shouldSetHealthChecksForRepository() { + Repository repository = RepositoryTestData.createHeartOfGold(); + repositoryPostProcessor.setCheckResults(repository.clone(), singleton(new HealthCheckFailure("HOG", "improbable", "This is not very probable"))); + + repositoryPostProcessor.postProcess(repository); + + assertThat(repository.getHealthCheckFailures()) + .extracting("id") + .containsExactly("HOG"); + } + + @Test + void shouldSetEmptyListOfHealthChecksWhenNoResultsExist() { + Repository repository = RepositoryTestData.createHeartOfGold(); + + repositoryPostProcessor.postProcess(repository); + + assertThat(repository.getHealthCheckFailures()) + .isNotNull() + .isEmpty(); + } + + @Test + void shouldSetHealthChecksForRepositoryInSetter() { + Repository repository = RepositoryTestData.createHeartOfGold(); + repositoryPostProcessor.setCheckResults(repository, singleton(new HealthCheckFailure("HOG", "improbable", "This is not very probable"))); + + assertThat(repository.getHealthCheckFailures()) + .extracting("id") + .containsExactly("HOG"); + } + + @Test + void shouldTriggerHealthCheckEventForNewFailure() { + Repository repository = RepositoryTestData.createHeartOfGold(); + repositoryPostProcessor.setCheckResults(repository, singleton(new HealthCheckFailure("HOG", "improbable", "This is not very probable"))); + + verify(eventBus).post(argThat(event -> { + HealthCheckEvent healthCheckEvent = (HealthCheckEvent) event; + assertThat(healthCheckEvent.getRepository()) + .isEqualTo(repository); + assertThat(healthCheckEvent.getPreviousFailures()) + .isEmpty(); + assertThat(((HealthCheckEvent) event).getCurrentFailures()) + .extracting("id") + .containsExactly("HOG"); + return true; + })); + } + + @Test + void shouldTriggerHealthCheckEventForDifferentFailure() { + Repository repository = RepositoryTestData.createHeartOfGold(); + repositoryPostProcessor.setCheckResults(repository, singleton(new HealthCheckFailure("HOG", "improbable", "This is not very probable"))); + repositoryPostProcessor.setCheckResults(repository, singleton(new HealthCheckFailure("VOG", "vogons", "Erased by Vogons"))); + + verify(eventBus).post(argThat(event -> { + HealthCheckEvent healthCheckEvent = (HealthCheckEvent) event; + if (healthCheckEvent.getPreviousFailures().isEmpty()) { + return false; // ignore event from first checks + } + assertThat((healthCheckEvent).getRepository()) + .isEqualTo(repository); + assertThat((healthCheckEvent).getPreviousFailures()) + .extracting("id") + .containsExactly("HOG"); + assertThat((healthCheckEvent).getCurrentFailures()) + .extracting("id") + .containsExactly("VOG"); + return true; + })); + } +}