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

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