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 0000000000..0b15e1b1c9 Binary files /dev/null and b/docs/de/user/repo/assets/repository-settings-general-health-check.png differ diff --git a/docs/de/user/repo/settings.md b/docs/de/user/repo/settings.md index fe8ebf9b61..622a4f6e41 100644 --- a/docs/de/user/repo/settings.md +++ b/docs/de/user/repo/settings.md @@ -21,9 +21,9 @@ Strategie `benutzerdefiniert` ausgewählt ist, kann zusätzlich zum Repository N Ein archiviertes Repository kann nicht mehr verändert werden. In dem Bereich "Repository exportieren" kann das Repository in unterschiedlichen Formaten exportiert werden. -Während eines laufenden Exports, kann auf das Repository nur lesend zugriffen werden. +Während eines laufenden Exports kann auf das Repository nur lesend zugriffen werden. Der Repository Export wird asynchron erstellt und auf dem Server gespeichert. -Existiert bereits ein Export für dieses Repository auf dem Server, wird dieser vorher gelöscht, da es immer nur einen Export pro Repository geben kann. +Existiert bereits ein Export für dieses Repository auf dem Server, wird dieser vorher gelöscht, da es immer nur einen Export pro Repository geben kann. Exporte werden 10 Tage nach deren Erstellung automatisch vom SCM-Server gelöscht. Falls ein Export existiert, wird über die blaue Info-Box angezeigt von wem, wann und wie dieser Export erzeugt wurde. @@ -39,6 +39,20 @@ Das Ausgabeformat des Repository kann über die angebotenen Optionen verändert ![Repository-Settings-General-Export](assets/repository-settings-general-export.png) +Der Bereich „Integritätsprüfung“ bietet die Möglichkeit, eine Integritätsprüfung des Repositories zu starten. Hier +werden (zum Teil ausführliche) Prüfungen ausgeführt, die z. B. sicherstellen, dass die Verzeichnisse korrekt +eingebunden sind. Wenn bei dem Zugriff auf ein Repository Fehler auftreten, sollte zunächst eine solche +Integritätsprüfung gestartet werden. Ein Teil dieser Prüfungen wird bei jedem Start des SCM-Managers ausgeführt. + +Werden bei einer dieser Integritätsprüfungen Fehler gefunden, wird auf der Repository-Übersicht sowie auf den +Detailseiten zum Repository neben dem Namen ein Tag „fehlerhaft" angezeigt. In den Einstellungen wird zudem eine Meldung +eingeblendet. Durch Klick auf diese Meldung oder die Tags wird ein Popup mit weiteren Details angezeigt. + +Der Server führt immer nur eine Prüfung zur Zeit durch. Es können jedoch für mehrere Repositories Prüfungen in die +Warteschlange gestellt werden, die dann nacheinander durchgeführt werden. + +![Repository-Settings-General-Health-Check](assets/repository-settings-general-health-check.png) + ### Berechtigungen Dank des fein granularen Berechtigungskonzepts des SCM-Managers können Nutzern und Gruppen, basierend auf definierbaren diff --git a/docs/en/user/repo/assets/repository-settings-general-health-check.png b/docs/en/user/repo/assets/repository-settings-general-health-check.png new file mode 100644 index 0000000000..61015f4cd9 Binary files /dev/null and b/docs/en/user/repo/assets/repository-settings-general-health-check.png differ diff --git a/docs/en/user/repo/settings.md b/docs/en/user/repo/settings.md index 4a0265326b..25ec1ae1a4 100644 --- a/docs/en/user/repo/settings.md +++ b/docs/en/user/repo/settings.md @@ -37,6 +37,19 @@ The output format of the repository can be changed via the offered options: ![Repository-Settings-General-Export](assets/repository-settings-general-export.png) +The section "Health Checks" provides the option to run health checks for the repository. These are (propably +extensive) checks, ensuring that for example the paths are mounted correctly. If errors occur while accessing +repositories, this should be your first place to look. A part of the checks are run every time SCM-Manager starts. + +If errors are detected during these checks, a tag is shown near the name of the repository in the overview +and on the header for the repository. Additionally there is a notification in the settings dialog for this +repository. By clicking this message or the tags, a popup is shown with more information regarding the failures. + +The server will run only one check at a time. Nonetheless you can enqueue multiple checks for different +repositories, which will be executed after one another. + +![Repository-Settings-General-Health-Check](assets/repository-settings-general-health-check.png) + ### Permissions Thanks to the finely granular permission concept of SCM-Manager, users and groups can be authorized based on definable diff --git a/gradle/changelog/health_checks.yaml b/gradle/changelog/health_checks.yaml new file mode 100644 index 0000000000..39c5db6614 --- /dev/null +++ b/gradle/changelog/health_checks.yaml @@ -0,0 +1,2 @@ +- type: added + description: Frontend for, and enhancement of health checks ([#1621](https://github.com/scm-manager/scm-manager/pull/1621)) diff --git a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java index 61fefa5afc..7723f35a3a 100644 --- a/scm-core/src/main/java/sonia/scm/SCMContextProvider.java +++ b/scm-core/src/main/java/sonia/scm/SCMContextProvider.java @@ -21,15 +21,18 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm; //~--- JDK imports ------------------------------------------------------------ -import java.io.Closeable; +import sonia.scm.version.Version; + import java.io.File; import java.nio.file.Path; +import static java.lang.String.format; + /** * The main class for retrieving the home and the version of the SCM-Manager. * This class is a singleton which can be retrieved via injection @@ -83,4 +86,17 @@ public interface SCMContextProvider { * @return version of the SCM-Manager */ String getVersion(); + + /** + * Returns the version of the SCM-Manager used in documentation urls (eg. version 2.17.0 and 2.17.1 will all result + * in 2.17.x). The default implementation works for versions with three parts (major version, minor version, + * and patch version, where the patch version will be replaces with an 'x'). + * + * @return version of the SCM-Manager used in documentation urls + * @since 2.17.0 + */ + default String getDocumentationVersion() { + Version parsedVersion = Version.parse(getVersion()); + return format("%s.%s.x", parsedVersion.getMajor(), parsedVersion.getMinor()); + } } diff --git a/scm-core/src/main/java/sonia/scm/repository/HealthCheckEvent.java b/scm-core/src/main/java/sonia/scm/repository/HealthCheckEvent.java new file mode 100644 index 0000000000..2d3549f72b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/repository/HealthCheckEvent.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 lombok.AllArgsConstructor; +import sonia.scm.event.Event; + +import java.util.Collection; + +import static java.util.Collections.unmodifiableCollection; + +/** + * This event is triggered whenever a health check was run and either found issues + * or issues reported earlier are fixed (that is, health has changed). + * + * @since 2.17.0 + */ +@Event +@AllArgsConstructor +public class HealthCheckEvent { + + private final Repository repository; + private final Collection 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; + })); + } +}