From 7656c2dc142a74f1315ea418e7feb92e757a35d1 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Wed, 10 Mar 2021 10:07:29 +0100 Subject: [PATCH] Add API for metrics based on Micrometer (#1576) --- gradle/changelog/metrics_api.yaml | 2 + gradle/dependencies.gradle | 6 +- scm-core/build.gradle | 3 + .../sonia/scm/metrics/MonitoringSystem.java | 64 +++++ .../java/sonia/scm/metrics/ScrapeTarget.java | 52 ++++ .../v2/resources/MetricsIndexEnricher.java | 72 ++++++ .../scm/api/v2/resources/MetricsResource.java | 101 ++++++++ .../scm/api/v2/resources/ResourceLinks.java | 17 ++ .../lifecycle/modules/ScmServletModule.java | 5 + .../scm/metrics/MeterRegistryProvider.java | 82 +++++++ .../resources/META-INF/scm/permissions.xml | 3 + .../main/resources/locales/de/plugins.json | 6 + .../main/resources/locales/en/plugins.json | 6 + .../resources/MetricsIndexEnricherTest.java | 226 ++++++++++++++++++ .../api/v2/resources/MetricsResourceTest.java | 162 +++++++++++++ .../metrics/MeterRegistryProviderTest.java | 96 ++++++++ 16 files changed, 902 insertions(+), 1 deletion(-) create mode 100644 gradle/changelog/metrics_api.yaml create mode 100644 scm-core/src/main/java/sonia/scm/metrics/MonitoringSystem.java create mode 100644 scm-core/src/main/java/sonia/scm/metrics/ScrapeTarget.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsIndexEnricher.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/metrics/MeterRegistryProvider.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsIndexEnricherTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsResourceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/metrics/MeterRegistryProviderTest.java diff --git a/gradle/changelog/metrics_api.yaml b/gradle/changelog/metrics_api.yaml new file mode 100644 index 0000000000..23c8e1e3e9 --- /dev/null +++ b/gradle/changelog/metrics_api.yaml @@ -0,0 +1,2 @@ +- type: added + description: API for metrics ([#1576](https://github.com/scm-manager/scm-manager/issues/1576)) diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index 898b189097..2ef6786bab 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -18,6 +18,7 @@ ext { hamcrestVersion = '2.1' mockitoVersion = '3.6.28' jerseyClientVersion = '1.19.4' + micrometerVersion = '1.6.4' nodeVersion = '14.15.1' yarnVersion = '1.22.5' @@ -165,6 +166,9 @@ ext { // rest api client for testing jerseyClientApi: "com.sun.jersey:jersey-client:${jerseyClientVersion}", - jerseyClientRuntime: "com.sun.jersey.contribs:jersey-apache-client:${jerseyClientVersion}" + jerseyClientRuntime: "com.sun.jersey.contribs:jersey-apache-client:${jerseyClientVersion}", + + // metrics + micrometerCore: "io.micrometer:micrometer-core:${micrometerVersion}" ] } diff --git a/scm-core/build.gradle b/scm-core/build.gradle index b576ff2329..b79ebb4983 100644 --- a/scm-core/build.gradle +++ b/scm-core/build.gradle @@ -101,6 +101,9 @@ dependencies { // compression implementation libraries.commonsCompress + // metrics + api libraries.micrometerCore + // tests testImplementation libraries.junitJupiterApi testImplementation libraries.junitJupiterParams diff --git a/scm-core/src/main/java/sonia/scm/metrics/MonitoringSystem.java b/scm-core/src/main/java/sonia/scm/metrics/MonitoringSystem.java new file mode 100644 index 0000000000..40fb0e878f --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/metrics/MonitoringSystem.java @@ -0,0 +1,64 @@ +/* + * 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.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import sonia.scm.plugin.ExtensionPoint; + +import java.util.Optional; + +/** + * Extension point to pass SCM-Manager metrics to a monitoring system. + * + * @since 2.15.0 + */ +@ExtensionPoint +public interface MonitoringSystem { + + /** + * Returns name of monitoring system. + * + * @return name of monitoring system + */ + String getName(); + + /** + * Returns registry of metrics provider. + * + * @return metrics registry + */ + MeterRegistry getRegistry(); + + /** + * Returns an optional scrape target. + * A scrape target is only needed if the monitoring system pulls the metrics over http. + * If the monitoring system uses a push based model, this method returns an empty optional. + * + * @return optional scrape target + */ + default Optional getScrapeTarget() { + return Optional.empty(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/metrics/ScrapeTarget.java b/scm-core/src/main/java/sonia/scm/metrics/ScrapeTarget.java new file mode 100644 index 0000000000..36beecaca4 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/metrics/ScrapeTarget.java @@ -0,0 +1,52 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.metrics; + + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Target for monitoring systems which scrape metrics from an http endpoint. + * + * @since 2.15.0 + */ +public interface ScrapeTarget { + + /** + * Returns content type of output format. + * + * @return content type + */ + String getContentType(); + + /** + * Writes received metrics to given output stream. + * + * @param outputStream Output stream the metrics will be written to. + * @throws IOException if an IO error is encountered + */ + void write(OutputStream outputStream) throws IOException; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsIndexEnricher.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsIndexEnricher.java new file mode 100644 index 0000000000..98d3bafe5f --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsIndexEnricher.java @@ -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. + */ + +package sonia.scm.api.v2.resources; + +import org.apache.shiro.SecurityUtils; +import sonia.scm.metrics.MonitoringSystem; +import sonia.scm.plugin.Extension; + +import javax.inject.Inject; +import javax.inject.Provider; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Extension +@Enrich(Index.class) +public class MetricsIndexEnricher implements HalEnricher { + + private final Provider resourceLinks; + private final List scrapeTargets; + + @Inject + public MetricsIndexEnricher(Provider resourceLinks, Set monitoringSystems) { + this.resourceLinks = resourceLinks; + this.scrapeTargets = scrapeTargets(monitoringSystems); + } + + private List scrapeTargets(Set monitoringSystems) { + return monitoringSystems.stream() + .filter(sys -> sys.getScrapeTarget().isPresent()) + .map(MonitoringSystem::getName) + .collect(Collectors.toList()); + } + + @Override + public void enrich(HalEnricherContext context, HalAppender appender) { + if (!isPermitted() || scrapeTargets.isEmpty()) { + return; + } + HalAppender.LinkArrayBuilder links = appender.linkArrayBuilder("metrics"); + for (String type : scrapeTargets) { + links.append(type, resourceLinks.get().metrics().forType(type)); + } + links.build(); + } + + private boolean isPermitted() { + return SecurityUtils.getSubject().isPermitted("metrics:read"); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsResource.java new file mode 100644 index 0000000000..5df1648e03 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MetricsResource.java @@ -0,0 +1,101 @@ +/* + * 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.api.v2.resources; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.apache.shiro.SecurityUtils; +import sonia.scm.NotFoundException; +import sonia.scm.metrics.MonitoringSystem; +import sonia.scm.metrics.ScrapeTarget; +import sonia.scm.web.VndMediaType; + +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static sonia.scm.ContextEntry.ContextBuilder.entity; + +/** + * Endpoint for pull based monitoring systems, which scrape for metrics. + * @since 2.15.0 + */ +@OpenAPIDefinition(tags = { + @Tag(name = "Metrics", description = "Scrape targets for monitoring systems") +}) +@Path("v2/metrics") +public class MetricsResource { + + private final Map providers = new HashMap<>(); + + @Inject + public MetricsResource(Set monitoringSystems) { + for (MonitoringSystem system : monitoringSystems) { + providers.put(system.getName(), system); + } + } + + @GET + @Path("{name}") + @Operation(summary = "Scrape target", description = "Scrape target for a monitoring system", tags = "Metrics") + @ApiResponse( + responseCode = "200", + description = "success" + ) + @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") + @ApiResponse(responseCode = "404", description = "unknown monitoring system or system without scrape target") + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + )) + public Response metrics(@PathParam("name") String name) { + SecurityUtils.getSubject().checkPermission("metrics:read"); + + MonitoringSystem system = providers.get(name); + if (system == null) { + throw new NotFoundException(MonitoringSystem.class, name); + } + + ScrapeTarget scrapeTarget = system.getScrapeTarget() + .orElseThrow(() -> NotFoundException.notFound(entity(ScrapeTarget.class, name).in(MonitoringSystem.class, name))); + + StreamingOutput output = scrapeTarget::write; + return Response.ok(output, scrapeTarget.getContentType()).build(); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 91b658baea..fa16c583ee 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -1085,4 +1085,21 @@ class ResourceLinks { return permissionLinkBuilder.method("getNamespaceResource").parameters(namespace).method("permissions").parameters().method(methodName).parameters(permissionName).href(); } } + + public MetricsLinks metrics() { + return new MetricsLinks(new LinkBuilder(scmPathInfoStore.get(), MetricsResource.class)); + } + + public static class MetricsLinks { + + private final LinkBuilder metricsLinkBuilder; + + private MetricsLinks(LinkBuilder metricsLinkBuilder) { + this.metricsLinkBuilder = metricsLinkBuilder; + } + + public String forType(String type) { + return metricsLinkBuilder.method("metrics").parameters(type).href(); + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index 4cd480cda4..931454eecf 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -30,6 +30,7 @@ import com.google.inject.multibindings.Multibinder; import com.google.inject.servlet.RequestScoped; import com.google.inject.servlet.ServletModule; import com.google.inject.throwingproviders.ThrowingProviderBinder; +import io.micrometer.core.instrument.MeterRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.Default; @@ -54,6 +55,7 @@ import sonia.scm.group.GroupDisplayManager; import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManagerProvider; import sonia.scm.group.xml.XmlGroupDAO; +import sonia.scm.metrics.MeterRegistryProvider; import sonia.scm.migration.MigrationDAO; import sonia.scm.net.SSLContextProvider; import sonia.scm.net.ahc.AdvancedHttpClient; @@ -170,6 +172,9 @@ class ScmServletModule extends ServletModule { // bind extensions pluginLoader.getExtensionProcessor().processAutoBindExtensions(binder()); + // bind metrics + bind(MeterRegistry.class).toProvider(MeterRegistryProvider.class).asEagerSingleton(); + // bind security stuff bind(LoginAttemptHandler.class).to(ConfigurableLoginAttemptHandler.class); bind(AuthorizationChangedEventProducer.class); diff --git a/scm-webapp/src/main/java/sonia/scm/metrics/MeterRegistryProvider.java b/scm-webapp/src/main/java/sonia/scm/metrics/MeterRegistryProvider.java new file mode 100644 index 0000000000..5aa9ed3eea --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/metrics/MeterRegistryProvider.java @@ -0,0 +1,82 @@ +/* + * 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.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.binder.jvm.ClassLoaderMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmGcMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmMemoryMetrics; +import io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics; +import io.micrometer.core.instrument.binder.system.ProcessorMetrics; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; + +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; +import java.util.Set; + +@Singleton +public class MeterRegistryProvider implements Provider { + + private final Set providerSet; + + @Inject + public MeterRegistryProvider(Set providerSet) { + this.providerSet = providerSet; + } + + @Override + public MeterRegistry get() { + MeterRegistry registry = createRegistry(); + if (!providerSet.isEmpty()) { + bindCommonMetrics(registry); + } + return registry; + } + + private MeterRegistry createRegistry() { + if (providerSet.size() == 1) { + return providerSet.iterator().next().getRegistry(); + } + return createCompositeRegistry(); + } + + private CompositeMeterRegistry createCompositeRegistry() { + CompositeMeterRegistry registry = new CompositeMeterRegistry(); + for (MonitoringSystem provider : providerSet) { + registry.add(provider.getRegistry()); + } + return registry; + } + + @SuppressWarnings("java:S2095") // we can't close JvmGcMetrics, but it should be ok + private void bindCommonMetrics(MeterRegistry registry) { + new ClassLoaderMetrics().bindTo(registry); + new JvmMemoryMetrics().bindTo(registry); + new JvmGcMetrics().bindTo(registry); + new ProcessorMetrics().bindTo(registry); + new JvmThreadMetrics().bindTo(registry); + } +} diff --git a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml index db5ac5c201..35d1a49181 100644 --- a/scm-webapp/src/main/resources/META-INF/scm/permissions.xml +++ b/scm-webapp/src/main/resources/META-INF/scm/permissions.xml @@ -83,5 +83,8 @@ plugin:read,write + + metrics:read + diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index b9c8adfe8b..6409798dd7 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -115,6 +115,12 @@ "description": "Darf die Berechtigungen auf Namespace-Ebene lesen und bearbeiten" } }, + "metrics": { + "read": { + "displayName": "Metriken lesen", + "description": "Darf Metriken über die zur Verfügung gestellten Endpunkte lesen" + } + }, "unknown": "Unbekannte Berechtigung" }, "verbs": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index 6a2487d09c..00f778784e 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -115,6 +115,12 @@ "description": "May read and modify the permissions set for namespaces" } }, + "metrics": { + "read": { + "displayName": "Read all metrics", + "description": "May read metrics over the exported scrape endpoints" + } + }, "unknown": "Unknown permission" }, "verbs": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsIndexEnricherTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsIndexEnricherTest.java new file mode 100644 index 0000000000..185ca638b8 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsIndexEnricherTest.java @@ -0,0 +1,226 @@ +/* + * 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.api.v2.resources; + +import com.google.common.collect.ImmutableSet; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +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.metrics.MonitoringSystem; +import sonia.scm.metrics.ScrapeTarget; + +import javax.inject.Provider; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class MetricsIndexEnricherTest { + + @Mock + private HalEnricherContext context; + + @Mock + private HalAppender appender; + + @Mock + private Subject subject; + + private Provider resourceLinks; + + @BeforeEach + void setUpResourceLinks() { + ScmPathInfoStore scmPathInfoStore = new ScmPathInfoStore(); + scmPathInfoStore.set(() -> URI.create("/")); + resourceLinks = () -> new ResourceLinks(scmPathInfoStore); + } + + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void tearDownSubject() { + ThreadContext.unbindSubject(); + } + + @Nested + class WithPermission { + + @BeforeEach + void prepareSubject() { + when(subject.isPermitted("metrics:read")).thenReturn(true); + } + + @Test + void shouldNotEnrichWithoutMonitoringSystems() { + MetricsIndexEnricher enricher = new MetricsIndexEnricher( + resourceLinks, + Collections.emptySet() + ); + enricher.enrich(context, appender); + + verify(appender, never()).linkArrayBuilder("metrics"); + } + + @Test + void shouldNotEnrichWithMonitoringSystemsWithScrapeTarget() { + MetricsIndexEnricher enricher = new MetricsIndexEnricher( + resourceLinks, + Collections.singleton(new NoScrapeMonitoringSystem()) + ); + enricher.enrich(context, appender); + + verify(appender, never()).linkArrayBuilder("metrics"); + } + + @Test + void shouldEnrichIndex() { + CapturingLinkArrayBuilder linkBuilder = new CapturingLinkArrayBuilder(); + when(appender.linkArrayBuilder("metrics")).thenReturn(linkBuilder); + + MetricsIndexEnricher enricher = new MetricsIndexEnricher( + resourceLinks, + ImmutableSet.of( + new NoScrapeMonitoringSystem(), + new ScrapeMonitoringSystem("one"), + new ScrapeMonitoringSystem("two")) + ); + enricher.enrich(context, appender); + + assertThat(linkBuilder.buildWasCalled).isTrue(); + assertThat(linkBuilder.links) + .containsEntry("one", "/v2/metrics/one") + .containsEntry("two", "/v2/metrics/two") + .hasSize(2); + } + } + + @Nested + class WithoutPermission { + + @BeforeEach + void prepareSubject() { + when(subject.isPermitted("metrics:read")).thenReturn(false); + } + + @Test + void shouldNotEnrichWithoutPermission() { + MetricsIndexEnricher enricher = new MetricsIndexEnricher( + resourceLinks, + ImmutableSet.of( + new ScrapeMonitoringSystem("one") + ) + ); + enricher.enrich(context, appender); + + verify(appender, never()).linkArrayBuilder("metrics"); + } + + } + + private static class NoScrapeMonitoringSystem implements MonitoringSystem { + + @Override + public String getName() { + return "noscrap"; + } + + @Override + public MeterRegistry getRegistry() { + return new SimpleMeterRegistry(); + } + } + + private static class ScrapeMonitoringSystem implements MonitoringSystem { + + private final String type; + + private ScrapeMonitoringSystem(String type) { + this.type = type; + } + + @Override + public String getName() { + return type; + } + + @Override + public MeterRegistry getRegistry() { + return new SimpleMeterRegistry(); + } + + @Override + public Optional getScrapeTarget() { + return Optional.of(new NoopScrapeTarget()); + } + } + + private static class NoopScrapeTarget implements ScrapeTarget { + + @Override + public String getContentType() { + return null; + } + + @Override + public void write(OutputStream outputStream) throws IOException { + + } + } + + private static class CapturingLinkArrayBuilder implements HalAppender.LinkArrayBuilder { + + private final Map links = new HashMap<>(); + private boolean buildWasCalled = false; + + @Override + public HalAppender.LinkArrayBuilder append(String name, String href) { + links.put(name, href); + return this; + } + + @Override + public void build() { + buildWasCalled = true; + } + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsResourceTest.java new file mode 100644 index 0000000000..686b4009a4 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MetricsResourceTest.java @@ -0,0 +1,162 @@ +/* + * 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.api.v2.resources; + +import com.google.common.collect.ImmutableSet; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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.metrics.MonitoringSystem; +import sonia.scm.metrics.ScrapeTarget; +import sonia.scm.web.RestDispatcher; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UnsupportedEncodingException; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; + +@ExtendWith(MockitoExtension.class) +class MetricsResourceTest { + + private RestDispatcher dispatcher; + + @Mock + private Subject subject; + + @BeforeEach + void setUpDispatcher() { + dispatcher = new RestDispatcher(); + dispatcher.addSingletonResource(new MetricsResource(ImmutableSet.of( + new NoScrapeMonitoringSystem(), + new ScrapeMonitoringSystem() + ))); + } + + @BeforeEach + void setUpSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void tearDownSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldReturn404ForUnknownMonitoringSystem() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/metrics/unknown"); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + + @Test + void shouldReturn404ForMonitoringSystemWithoutScrapeTarget() throws URISyntaxException { + MockHttpRequest request = MockHttpRequest.get("/v2/metrics/noscrape"); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_NOT_FOUND); + } + + @Test + void shouldReturn403WithoutPermission() throws URISyntaxException { + doThrow(new AuthorizationException("not permitted")).when(subject).checkPermission("metrics:read"); + MockHttpRequest request = MockHttpRequest.get("/v2/metrics/scrape"); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + void shouldReturnMetrics() throws URISyntaxException, UnsupportedEncodingException { + MockHttpRequest request = MockHttpRequest.get("/v2/metrics/scrape"); + MockHttpResponse response = new MockHttpResponse(); + dispatcher.invoke(request, response); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK); + assertThat(response.getOutputHeaders().getFirst("Content-Type").toString()).hasToString("text/plain;charset=UTF-8"); + assertThat(response.getContentAsString()).isEqualTo("hello"); + } + + private static class NoScrapeMonitoringSystem implements MonitoringSystem { + + @Override + public String getName() { + return "noscrape"; + } + + @Override + public MeterRegistry getRegistry() { + return new SimpleMeterRegistry(); + } + } + + private static class ScrapeMonitoringSystem implements MonitoringSystem { + + @Override + public String getName() { + return "scrape"; + } + + @Override + public MeterRegistry getRegistry() { + return new SimpleMeterRegistry(); + } + + @Override + public Optional getScrapeTarget() { + return Optional.of(new HelloScrapeTarget()); + } + } + + private static class HelloScrapeTarget implements ScrapeTarget { + + @Override + public String getContentType() { + return "text/plain"; + } + + @Override + public void write(OutputStream outputStream) throws IOException { + outputStream.write("hello".getBytes(StandardCharsets.UTF_8)); + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/metrics/MeterRegistryProviderTest.java b/scm-webapp/src/test/java/sonia/scm/metrics/MeterRegistryProviderTest.java new file mode 100644 index 0000000000..2759f52e6b --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/metrics/MeterRegistryProviderTest.java @@ -0,0 +1,96 @@ +/* + * 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.metrics; + +import com.google.common.collect.ImmutableSet; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.composite.CompositeMeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; + + +class MeterRegistryProviderTest { + + @Test + void shouldReturnNoopRegistry() { + MeterRegistryProvider provider = new MeterRegistryProvider(Collections.emptySet()); + MeterRegistry registry = provider.get(); + registry.counter("sample").increment(); + assertThat(registry.get("sample").counter().count()).isEqualTo(0.0); + } + + @Test + void shouldNotRegisterCommonMetricsForDefaultRegistry() { + MeterRegistryProvider provider = new MeterRegistryProvider(Collections.emptySet()); + assertThat(provider.get().getMeters()).withFailMessage("no common metrics should be registered").isEmpty(); + } + + @Test + void shouldStoreMetrics() { + MeterRegistryProvider provider = new MeterRegistryProvider(Collections.singleton(new SimpleMonitoringSystem())); + MeterRegistry registry = provider.get(); + registry.counter("sample").increment(); + assertThat(registry.get("sample").counter().count()).isEqualTo(1.0); + } + + @Test + void shouldRegisterCommonMetrics() { + MeterRegistryProvider provider = new MeterRegistryProvider(Collections.singleton(new SimpleMonitoringSystem())); + MeterRegistry registry = provider.get(); + assertThat(registry.getMeters()).isNotEmpty(); + } + + @Test + void shouldCreateCompositeMeterRegistry() { + MeterRegistryProvider provider = new MeterRegistryProvider( + ImmutableSet.of(new SimpleMonitoringSystem(), new SimpleMonitoringSystem()) + ); + assertThat(provider.get()).isInstanceOf(CompositeMeterRegistry.class); + } + + @Test + void shouldReturnSingleRegistryUnwrapped() { + MeterRegistryProvider provider = new MeterRegistryProvider(Collections.singleton(new SimpleMonitoringSystem())); + assertThat(provider.get()).isInstanceOf(SimpleMeterRegistry.class); + } + + private static class SimpleMonitoringSystem implements MonitoringSystem { + + @Override + public String getName() { + return "simple"; + } + + @Override + public MeterRegistry getRegistry() { + return new SimpleMeterRegistry(); + } + } + +}