Enable Health Checks (#1621)

In the release of version 2.0.0 of SCM-Manager, the health checks had been neglected. This makes them visible again in the frontend and adds the ability to trigger them. In addition there are two types of health checks: The "normal" ones, now called "light checks", that are run on startup, and more intense checks run only on request.

As a change to version 1.x, health checks will no longer be persisted for repositories.

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
René Pfeuffer
2021-04-21 10:09:23 +02:00
committed by GitHub
parent 893cf4af4c
commit 1e83c34823
61 changed files with 2162 additions and 106 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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

View File

@@ -0,0 +1,2 @@
- type: added
description: Frontend for, and enhancement of health checks ([#1621](https://github.com/scm-manager/scm-manager/pull/1621))

View File

@@ -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());
}
}

View File

@@ -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<HealthCheckFailure> previousFailures;
private final Collection<HealthCheckFailure> currentFailures;
public Repository getRepository() {
return repository;
}
public Collection<HealthCheckFailure> getPreviousFailures() {
return unmodifiableCollection(previousFailures);
}
public Collection<HealthCheckFailure> getCurrentFailures() {
return unmodifiableCollection(currentFailures);
}
}

View File

@@ -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 <code>{0}</code>
* 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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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<HealthCheckFailure> healthCheckFailures;
private String id;
private Long lastModified;

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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.
*

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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();
}
}

View File

@@ -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."));
}
}
}

View File

@@ -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<Feature> 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -42,8 +42,16 @@ public class SvnRepositoryServiceProvider extends RepositoryServiceProvider {
//J-
public static final Set<Command> 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);
}
}

View File

@@ -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();
}
}

View File

@@ -232,6 +232,27 @@ export const useUnarchiveRepository = () => {
};
};
export const useRunHealthCheck = () => {
const queryClient = useQueryClient();
const { mutate, isLoading, error, data } = useMutation<unknown, Error, Repository>(
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<ExportInfo> => {
const link = requiredLink(repository, "exportInfo");
//TODO Refetch while exporting to update the page

View File

@@ -35,9 +35,19 @@ type Props = {
active: boolean;
className?: string;
headColor?: string;
headTextColor?: string;
};
export const Modal: FC<Props> = ({ title, closeFunction, body, footer, active, className, headColor = "light" }) => {
export const Modal: FC<Props> = ({
title,
closeFunction,
body,
footer,
active,
className,
headColor = "light",
headTextColor = "black"
}) => {
const portalRootElement = usePortalRootElement("modalsRoot");
if (!portalRootElement) {
@@ -56,7 +66,7 @@ export const Modal: FC<Props> = ({ title, closeFunction, body, footer, active, c
<div className="modal-background" onClick={closeFunction} />
<div className="modal-card">
<header className={classNames("modal-card-head", `has-background-${headColor}`)}>
<p className="modal-card-title is-marginless">{title}</p>
<p className={`modal-card-title is-marginless has-text-${headTextColor}`}>{title}</p>
<button className="delete" aria-label="close" onClick={closeFunction} />
</header>
<section className="modal-card-body">{body}</section>

View File

@@ -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<Props> = ({ active, closeFunction, failures }) => {
const [t] = useTranslation("repos");
const footer = <Button label={t("healthCheckFailure.close")} action={closeFunction} color="grey" />;
return (
<Modal
body={
<div className={"content"}>
<HealthCheckFailureList failures={failures} />
</div>
}
title={t("healthCheckFailure.title")}
closeFunction={closeFunction}
active={active}
footer={footer}
headColor={"danger"}
headTextColor={"white"}
/>
);
};
export default HealthCheckFailureDetail;

View File

@@ -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<Props> = ({ 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 (
<li>
<em>{summary}</em>
<br />
{description}
<br />
{failure.url && (
<a href={failure.url} target="_blank" rel="noreferrer">
{t("healthCheckFailures.detailUrl")}
</a>
)}
</li>
);
};
const failureComponents = failures.map(failureLine);
return <ul>{failureComponents}</ul>;
};
export default HealthCheckFailureList;

View File

@@ -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<Props> {
class RepositoryEntry extends React.Component<Props, State> {
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<Props> {
repositoryFlags.push(<RepositoryTag title={t("exporting.tooltip")}>{t("repository.exporting")}</RepositoryTag>);
}
if (repository.healthCheckFailures && repository.healthCheckFailures.length > 0) {
repositoryFlags.push(
<RepositoryWarnTag
title={t("healthCheckFailure.tooltip")}
onClick={() => {
this.setState({ showHealthCheck: true });
}}
>
{t("repository.healthCheckFailure")}
</RepositoryWarnTag>
);
}
return (
<>
<ExtensionPoint name="repository.card.beforeTitle" props={{ repository }} />
@@ -168,16 +204,27 @@ class RepositoryEntry extends React.Component<Props> {
const footerLeft = this.createFooterLeft(repository, repositoryLink);
const footerRight = this.createFooterRight(repository, baseDate);
const title = this.createTitle();
return (
<CardColumn
avatar={<RepositoryAvatar repository={repository} />}
title={title}
description={repository.description}
link={repositoryLink}
footerLeft={footerLeft}
footerRight={footerRight}
const modal = (
<HealthCheckFailureDetail
closeFunction={() => this.setState({ showHealthCheck: false })}
active={this.state.showHealthCheck}
failures={repository.healthCheckFailures}
/>
);
return (
<>
{modal}
<CardColumn
avatar={<RepositoryAvatar repository={repository} />}
title={title}
description={repository.description}
link={repositoryLink}
footerLeft={footerLeft}
footerRight={footerRight}
/>
</>
);
}
}

View File

@@ -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,

View File

@@ -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 & {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Props> = ({ repository }) => {
return (
<>
<HealthCheckWarning repository={repository} />
<Subtitle subtitle={t("repositoryForm.subtitle")} />
<ErrorNotification error={error} />
<RepositoryForm repository={repository} loading={isLoading} modifyRepository={update} />
<ExtensionPoint name="repo-config.details" props={extensionProps} renderAll={true}/>
<ExtensionPoint name="repo-config.details" props={extensionProps} renderAll={true} />
{repository._links.exportInfo && <ExportRepository repository={repository} />}
<ExtensionPoint name="repo-config.route" props={extensionProps} renderAll={true} />
{(repository._links.runHealthCheck || repository.healthCheckRunning) && (
<RunHealthCheck repository={repository} />
)}
<RepositoryDangerZone repository={repository} indexLinks={indexLinks} />
</>
);

View File

@@ -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<Props> = ({ repository }) => {
const [t] = useTranslation("repos");
const [showHealthCheck, setShowHealthCheck] = useState(false);
if (repository.healthCheckFailures?.length === 0) {
return null;
}
const modal = (
<HealthCheckFailureDetail
closeFunction={() => setShowHealthCheck(false)}
active={showHealthCheck}
failures={repository.healthCheckFailures}
/>
);
return (
<Notification type="danger">
{modal}
<div className={"has-cursor-pointer"} onClick={() => setShowHealthCheck(true)}>
<div>{t("repositoryForm.healthCheckWarning.title")}</div>
<div className={"is-small"}>{t("repositoryForm.healthCheckWarning.subtitle")}</div>
</div>
</Notification>
);
};
export default HealthCheckWarning;

View File

@@ -76,7 +76,6 @@ const RepositoryDangerZone: FC<Props> = ({ repository, indexLinks }) => {
if (repository?._links?.unarchive) {
dangerZone.push(<UnarchiveRepo repository={repository} />);
}
if (dangerZone.length === 0) {
return null;
}

View File

@@ -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<UrlParams>();
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(
<Tooltip message={t("healthCheckFailure.tooltip")}>
<RepositoryWarnTag className="is-size-6" onClick={() => setShowHealthCheck(true)}>
{t("repository.healthCheckFailure")}
</RepositoryWarnTag>
</Tooltip>
);
}
const titleComponent = (
<>
<RouteLink to={`/repos/${repository.namespace}/`} className={"has-text-dark"}>
@@ -221,6 +242,14 @@ const RepositoryRoot = () => {
return `${url}/code/changesets`;
};
const modal = (
<HealthCheckFailureDetail
closeFunction={() => setShowHealthCheck(false)}
active={showHealthCheck}
failures={repository.healthCheckFailures}
/>
);
return (
<StateMenuContextProvider>
<Page
@@ -233,6 +262,7 @@ const RepositoryRoot = () => {
</>
}
>
{modal}
<CustomQueryFlexWrappedColumns>
<PrimaryContentColumn>
<Switch>

View File

@@ -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<Props> = ({ repository }) => {
const { isLoading, error, runHealthCheck } = useRunHealthCheck();
const [t] = useTranslation("repos");
const runHealthCheckCallback = () => {
runHealthCheck(repository);
};
return (
<>
<hr />
<ErrorNotification error={error} />
<Subtitle>{t("runHealthCheck.subtitle")}</Subtitle>
<p>
{repository.healthCheckRunning
? t("runHealthCheck.descriptionRunning")
: t("runHealthCheck.descriptionNotRunning")}
</p>
<Level
right={
<MarginTopButton
color="warning"
icon="heartbeat"
label={t("runHealthCheck.button")}
action={runHealthCheckCallback}
loading={isLoading || repository.healthCheckRunning}
/>
}
/>
</>
);
};
export default RunHealthCheck;

View File

@@ -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;

View File

@@ -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);

View File

@@ -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<Repository, RepositoryDto> 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());

View File

@@ -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<Reposit
private RepositoryServiceFactory serviceFactory;
@Inject
private Set<NamespaceStrategy> 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<Reposit
linksBuilder.single(link("changesets", resourceLinks.changeset().all(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("sources", resourceLinks.source().selfWithoutRevision(repository.getNamespace(), repository.getName())));
linksBuilder.single(link("paths", resourceLinks.repository().paths(repository.getNamespace(), repository.getName())));
if (RepositoryPermissions.healthCheck(repository).isPermitted() && !healthCheckService.checkRunning(repository)) {
linksBuilder.single(link("runHealthCheck", resourceLinks.repository().runHealthCheck(repository.getNamespace(), repository.getName())));
}
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), repository);
return new RepositoryDto(linksBuilder.build(), embeddedBuilder.build());
RepositoryDto repositoryDto = new RepositoryDto(linksBuilder.build(), embeddedBuilder.build());
repositoryDto.setHealthCheckRunning(healthCheckService.checkRunning(repository));
return repositoryDto;
}
private boolean isRenameNamespacePossible() {

View File

@@ -410,6 +410,10 @@ class ResourceLinks {
String paths(String namespace, String name) {
return repositoryPathsLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("paths").parameters().method("collect").parameters("_REVISION_").href().replace("_REVISION_", "{revision}");
}
String runHealthCheck(String namespace, String name) {
return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("runHealthCheck").parameters().href();
}
}
RepositoryCollectionLinks repositoryCollection() {

View File

@@ -66,11 +66,13 @@ import sonia.scm.net.ahc.XmlContentTransformer;
import sonia.scm.plugin.DefaultPluginManager;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginManager;
import sonia.scm.repository.DefaultHealthCheckService;
import sonia.scm.repository.DefaultNamespaceManager;
import sonia.scm.repository.DefaultRepositoryManager;
import sonia.scm.repository.DefaultRepositoryProvider;
import sonia.scm.repository.DefaultRepositoryRoleManager;
import sonia.scm.repository.HealthCheckContextListener;
import sonia.scm.repository.HealthCheckService;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.repository.NamespaceStrategyProvider;
@@ -259,6 +261,8 @@ class ScmServletModule extends ServletModule {
bind(RootURL.class).to(DefaultRootURL.class);
bind(PermissionProvider.class).to(RepositoryPermissionProvider.class);
bind(HealthCheckService.class).to(DefaultHealthCheckService.class);
}
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -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());
}
}

View File

@@ -77,14 +77,16 @@ public class DefaultRepositoryManager extends AbstractRepositoryManager {
private final Set<Type> types;
private final Provider<NamespaceStrategy> namespaceStrategyProvider;
private final ManagerDaoAdapter<Repository> managerDaoAdapter;
private final RepositoryPostProcessor repositoryPostProcessor;
@Inject
public DefaultRepositoryManager(SCMContextProvider contextProvider, KeyGenerator keyGenerator,
RepositoryDAO repositoryDAO, Set<RepositoryHandler> handlerSet,
Provider<NamespaceStrategy> namespaceStrategyProvider) {
Provider<NamespaceStrategy> 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<Repository> 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;
}
}

View File

@@ -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 -------------------------------------------------------------

View File

@@ -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);
}

View File

@@ -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<HealthCheck> checks;
private final RepositoryManager repositoryManager;
private final RepositoryServiceFactory repositoryServiceFactory;
private final RepositoryPostProcessor repositoryPostProcessor;
private final Collection<String> checksRunning = synchronizedCollection(new HashSet<>());
private final ExecutorService healthCheckExecutor = Executors.newSingleThreadExecutor();
@Inject
public HealthChecker(Set<HealthCheck> checks,
RepositoryManager repositoryManager) {
HealthChecker(Set<HealthCheck> 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);
}
}

View File

@@ -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<String, List<HealthCheckFailure>> checkResults = new HashMap<>();
@Inject
RepositoryPostProcessor(ScmEventBus eventBus) {
this.eventBus = eventBus;
}
void setCheckResults(Repository repository, Collection<HealthCheckFailure> failures) {
List<HealthCheckFailure> oldFailures = getCheckResults(repository.getId());
List<HealthCheckFailure> 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<HealthCheckFailure> getCheckResults(String repositoryId) {
return checkResults.getOrDefault(repositoryId, emptyList());
}
}

View File

@@ -38,6 +38,9 @@
<permission>
<value>repository:read,rename:*</value>
</permission>
<permission>
<value>repository:read,healthCheck:*</value>
</permission>
<permission>
<value>repository:*</value>
</permission>

View File

@@ -35,6 +35,7 @@
<verb>permissionWrite</verb>
<verb read-only="true">archive</verb>
<verb read-only="true">export</verb>
<verb>healthCheck</verb>
<verb>*</verb>
</verbs>
<roles>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Predicate<Repository>> filterCaptor;

View File

@@ -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));
}
}

View File

@@ -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<NamespaceStrategy> 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);
}

View File

@@ -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();

View File

@@ -106,6 +106,7 @@ import sonia.scm.TempSCMContextProvider;
public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
private RepositoryDAO repositoryDAO;
private RepositoryPostProcessor postProcessor = mock(RepositoryPostProcessor.class);
static {
ThreadContext.unbindSubject();
@@ -181,6 +182,7 @@ public class DefaultRepositoryManagerTest extends ManagerTestBase<Repository> {
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<Repository> {
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<Repository> {
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<Repository> {
private Repository createRepository(Repository repository) {
manager.create(repository);
assertNotNull(repository.getId());
assertNotNull(manager.get(repository.getId()));
assertTrue(repository.getCreationDate() > 0);
return repository;
}

View File

@@ -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);
}
}

View File

@@ -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;
}));
}
}