From 0018bd4287f6ad3a465a4dfcbe29a7a89c5dfb6e Mon Sep 17 00:00:00 2001 From: Ikhun Um Date: Fri, 30 Aug 2024 17:55:37 +0900 Subject: [PATCH] Add `ManagementService` Motivation: Central Dogma does not provide a way to dump threads or heap data. Modifications: - Add `ManagementConfig` to config a management service using `dogma.json` and `CentralDogmaBuilder` - Register `ManagementService` based on the options specified in `ManagementService` Example: - Serve a management service at the same ports of the main server ``` { "management": { "port": 0 } } ``` - Serve a management service only at an internal port which won't be exposed externally. ``` { "management": { "address": "127.0.0.1" "port": 36463 } } ``` Result: You can now configure a managemet service to dump the thread information or heap data. --- .../centraldogma/server/CentralDogma.java | 39 ++++- .../server/CentralDogmaBuilder.java | 13 +- .../server/CentralDogmaConfig.java | 18 ++- .../centraldogma/server/ManagementConfig.java | 134 ++++++++++++++++++ .../server/ManagementServiceTest.java | 103 ++++++++++++++ site/src/sphinx/setup-configuration.rst | 34 ++++- .../internal/CentralDogmaRuleDelegate.java | 25 ++-- 7 files changed, 349 insertions(+), 17 deletions(-) create mode 100644 server/src/main/java/com/linecorp/centraldogma/server/ManagementConfig.java create mode 100644 server/src/test/java/com/linecorp/centraldogma/server/ManagementServiceTest.java diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index 5c5610c688..4c4e19642c 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -77,6 +77,7 @@ import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.common.MediaType; import com.linecorp.armeria.common.ServerCacheControl; +import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.metric.MeterIdPrefixFunction; import com.linecorp.armeria.common.prometheus.PrometheusMeterRegistries; import com.linecorp.armeria.common.util.EventLoopGroups; @@ -106,6 +107,7 @@ import com.linecorp.armeria.server.healthcheck.HealthCheckService; import com.linecorp.armeria.server.healthcheck.SettableHealthChecker; import com.linecorp.armeria.server.logging.AccessLogWriter; +import com.linecorp.armeria.server.management.ManagementService; import com.linecorp.armeria.server.metric.MetricCollectingService; import com.linecorp.armeria.server.prometheus.PrometheusExpositionService; import com.linecorp.armeria.server.thrift.THttpService; @@ -568,7 +570,11 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, sb.verboseResponses(true); cfg.ports().forEach(sb::port); - if (cfg.ports().stream().anyMatch(ServerPort::hasTls)) { + final boolean needsTls = + cfg.ports().stream().anyMatch(ServerPort::hasTls) || + (cfg.managementConfig() != null && cfg.managementConfig().protocol().isTls()); + + if (needsTls) { try { final TlsConfig tlsConfig = cfg.tls(); if (tlsConfig != null) { @@ -610,6 +616,7 @@ private Server startServer(ProjectManager pm, CommandExecutor executor, sb.service(HEALTH_CHECK_PATH, HealthCheckService.builder() .checkers(serverHealth) .build()); + configManagement(sb, config().managementConfig()); sb.serviceUnder("/docs/", DocService.builder() @@ -914,6 +921,36 @@ private static void configCors(ServerBuilder sb, @Nullable CorsConfig corsConfig .newDecorator()); } + private static void configManagement(ServerBuilder sb, @Nullable ManagementConfig managementConfig) { + if (managementConfig == null) { + return; + } + + // curl -L https://
:/internal/management/jvm/threaddump + // curl -L https://
:/internal/management/jvm/heapdump -o heapdump.hprof + final int port = managementConfig.port(); + if (port == 0) { + logger.info("'management.port' is 0, using the same ports as 'ports'."); + sb.route() + .pathPrefix(managementConfig.path()) + .defaultServiceName("management") + .build(ManagementService.of()); + } else { + final SessionProtocol managementProtocol = managementConfig.protocol(); + final String address = managementConfig.address(); + if (address == null) { + sb.port(new ServerPort(port, managementProtocol)); + } else { + sb.port(new ServerPort(new InetSocketAddress(address, port), managementProtocol)); + } + sb.virtualHost(port) + .route() + .pathPrefix(managementConfig.path()) + .defaultServiceName("management") + .build(ManagementService.of()); + } + } + private static Function contentEncodingDecorator() { return delegate -> EncodingService .builder() diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java index 4a8f6af7e8..a93f2c062b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java @@ -132,6 +132,8 @@ public final class CentralDogmaBuilder { private CorsConfig corsConfig; private final List pluginConfigs = new ArrayList<>(); + @Nullable + private ManagementConfig managementConfig; /** * Creates a new builder with the specified data directory. @@ -529,6 +531,15 @@ public CentralDogmaBuilder pluginConfigs(PluginConfig... pluginConfigs) { return this; } + /** + * Enables a management service with the specified {@link ManagementConfig}. + */ + public CentralDogmaBuilder management(ManagementConfig managementConfig) { + requireNonNull(managementConfig, "managementConfig"); + this.managementConfig = managementConfig; + return this; + } + /** * Returns a newly-created {@link CentralDogma} server. */ @@ -562,6 +573,6 @@ private CentralDogmaConfig buildConfig() { maxRemovedRepositoryAgeMillis, gracefulShutdownTimeout, webAppEnabled, webAppTitle,replicationConfig, null, accessLogFormat, authCfg, quotaConfig, - corsConfig, pluginConfigs); + corsConfig, pluginConfigs, managementConfig); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java index ae999d0efa..f914d37f4e 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java @@ -266,11 +266,14 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, private final List pluginConfigs; private final Map, PluginConfig> pluginConfigMap; + @Nullable + private final ManagementConfig managementConfig; + CentralDogmaConfig( @JsonProperty(value = "dataDir", required = true) File dataDir, @JsonProperty(value = "ports", required = true) @JsonDeserialize(contentUsing = ServerPortDeserializer.class) - List ports, + List ports, @JsonProperty("tls") @Nullable TlsConfig tls, @JsonProperty("trustedProxyAddresses") @Nullable List trustedProxyAddresses, @JsonProperty("clientAddressSources") @Nullable List clientAddressSources, @@ -291,7 +294,8 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, @JsonProperty("authentication") @Nullable AuthConfig authConfig, @JsonProperty("writeQuotaPerRepository") @Nullable QuotaConfig writeQuotaPerRepository, @JsonProperty("cors") @Nullable CorsConfig corsConfig, - @JsonProperty("pluginConfigs") @Nullable List pluginConfigs) { + @JsonProperty("pluginConfigs") @Nullable List pluginConfigs, + @JsonProperty("management") @Nullable ManagementConfig managementConfig) { this.dataDir = requireNonNull(dataDir, "dataDir"); this.ports = ImmutableList.copyOf(requireNonNull(ports, "ports")); @@ -339,6 +343,7 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, this.pluginConfigs = firstNonNull(pluginConfigs, ImmutableList.of()); pluginConfigMap = this.pluginConfigs.stream().collect( toImmutableMap(PluginConfig::getClass, Function.identity())); + this.managementConfig = managementConfig; } /** @@ -568,6 +573,15 @@ public Map, PluginConfig> pluginConfigMap() { return pluginConfigMap; } + /** + * Returns the {@link ManagementConfig}. + */ + @Nullable + @JsonProperty("management") + public ManagementConfig managementConfig() { + return managementConfig; + } + @Override public String toString() { try { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/ManagementConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/ManagementConfig.java new file mode 100644 index 0000000000..b83b729754 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/ManagementConfig.java @@ -0,0 +1,134 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server; + +import static com.google.common.base.MoreObjects.firstNonNull; +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; + +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.server.management.ManagementService; + +/** + * A configuration for the {@link ManagementService}. + */ +public final class ManagementConfig { + private static final String DEFAULT_PROTOCOL = "http"; + private static final String DEFAULT_PATH = "/internal/management"; + + private final SessionProtocol protocol; + private final @Nullable String address; + private final int port; + private final String path; + + /** + * Creates a new instance. + */ + @JsonCreator + public ManagementConfig(@JsonProperty("protocol") @Nullable String protocol, + @JsonProperty("address") @Nullable String address, + @JsonProperty("port") int port, + @JsonProperty("path") @Nullable String path) { + this(SessionProtocol.of(firstNonNull(protocol, DEFAULT_PROTOCOL)), + address, port, path); + } + + /** + * Creates a new instance. + */ + public ManagementConfig(@Nullable SessionProtocol protocol, + @Nullable String address, + int port, + @Nullable String path) { + protocol = firstNonNull(protocol, SessionProtocol.HTTP); + checkArgument(protocol != SessionProtocol.PROXY, "protocol: %s (expected: one of %s)", + protocol, SessionProtocol.httpAndHttpsValues()); + this.protocol = protocol; + this.address = address; + checkArgument(port >= 0 && port <= 65535, "%s: %s (expected: 0-65535)", "management.port", port); + this.port = port; + this.path = firstNonNull(path, DEFAULT_PATH); + } + + /** + * Returns the protocol of the management service. + */ + @JsonProperty("protocol") + public SessionProtocol protocol() { + return protocol; + } + + /** + * Returns the address of the management service. + */ + @JsonProperty("address") + public @Nullable String address() { + return address; + } + + /** + * Returns the port of the management service. + */ + @JsonProperty("port") + public int port() { + return port; + } + + /** + * Returns the path of the management service. + */ + @JsonProperty("path") + public String path() { + return path; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ManagementConfig)) { + return false; + } + final ManagementConfig that = (ManagementConfig) o; + return port == that.port && + protocol == that.protocol && + Objects.equals(address, that.address) && + path.equals(that.path); + } + + @Override + public int hashCode() { + return Objects.hash(protocol, address, port, path); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("protocol", protocol) + .add("address", address) + .add("port", port) + .add("path", path) + .toString(); + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/ManagementServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/ManagementServiceTest.java new file mode 100644 index 0000000000..c0e9ca8a3e --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/ManagementServiceTest.java @@ -0,0 +1,103 @@ +/* + * Copyright 2024 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.core.JsonProcessingException; + +import com.linecorp.armeria.client.BlockingWebClient; +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.internal.common.util.PortUtil; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class ManagementServiceTest { + + private static int tlsPort; + + @RegisterExtension + static final CentralDogmaExtension noManagement = new CentralDogmaExtension(); + + @RegisterExtension + static final CentralDogmaExtension management = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.management(new ManagementConfig((String) null, null, 0, null)); + } + }; + + @RegisterExtension + static final CentralDogmaExtension managementWithFullOptions = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + tlsPort = PortUtil.unusedTcpPort(); + builder.management( + new ManagementConfig(SessionProtocol.HTTPS, "127.0.0.1", tlsPort, "/custom/management")); + } + }; + + @Test + void disableManagementServiceByDefault() { + final BlockingWebClient client = noManagement.blockingHttpClient(); + assertThat(client.get("/internal/management").status()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + void managementServiceWithDefaultOption() { + final BlockingWebClient client = management.blockingHttpClient(); + final AggregatedHttpResponse response = client.get("/internal/management/jvm/threaddump"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).contains("repository-worker-"); + } + + @Test + void managementServiceWithFullOptions() { + final BlockingWebClient client = + WebClient.builder("https://127.0.0.1:" + tlsPort) + .factory(ClientFactory.insecure()) + .build() + .blocking(); + final AggregatedHttpResponse response = client.get("/custom/management/jvm/threaddump"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).contains("repository-worker-"); + } + + @Test + void testJsonDeserialization() throws JsonProcessingException { + final String json = + '{' + + "\"protocol\":\"https\"," + + "\"address\":\"127.0.0.1\"," + + "\"port\":8443," + + "\"path\":\"/custom/management\"" + + '}'; + final ManagementConfig managementConfig = Jackson.readValue(json, ManagementConfig.class); + + assertThat(managementConfig.protocol()).isEqualTo(SessionProtocol.HTTPS); + assertThat(managementConfig.port()).isEqualTo(8443); + assertThat(managementConfig.address()).isEqualTo("127.0.0.1"); + assertThat(managementConfig.path()).isEqualTo("/custom/management"); + } +} diff --git a/site/src/sphinx/setup-configuration.rst b/site/src/sphinx/setup-configuration.rst index 902743b104..88faedf428 100644 --- a/site/src/sphinx/setup-configuration.rst +++ b/site/src/sphinx/setup-configuration.rst @@ -58,7 +58,13 @@ defaults: "maxNumFilesPerMirror": null, "maxNumBytesPerMirror": null } - ] + ], + "management": { + "address": "127.0.0.1", + "port": 36463, + "protocol": null, + "path": null + } } Core properties @@ -230,6 +236,32 @@ Core properties - the list of plugin configuration. See :ref:`plugins` for more information. +- ``management`` + + - the management server configuration. Read `ManagementService API documentation `_ + to know more about the management service. + + - ``port`` (integer) + + - the port number of the management service. + If ``0``, the management service uses the same port as the main service. + + - ``address`` (string) + + - the IP address of the management service. If ``null``, the management will listen to all network interfaces. + + - this option is ignored if ``port`` is set to ``0``. + + - ``protocol`` + + - the protocol of the management service. ``http`` and ``https`` are supported. If not specified, ``http`` is used. + + - this option is ignored if ``port`` is set to ``0``. + + - ``path`` + + - the path of the management service. If not specified, the management service is mounted at ``/internal/management``. + .. _replication: Configuring replication diff --git a/testing/common/src/main/java/com/linecorp/centraldogma/testing/internal/CentralDogmaRuleDelegate.java b/testing/common/src/main/java/com/linecorp/centraldogma/testing/internal/CentralDogmaRuleDelegate.java index 4d0e9d2269..c159c2334e 100644 --- a/testing/common/src/main/java/com/linecorp/centraldogma/testing/internal/CentralDogmaRuleDelegate.java +++ b/testing/common/src/main/java/com/linecorp/centraldogma/testing/internal/CentralDogmaRuleDelegate.java @@ -67,7 +67,7 @@ public class CentralDogmaRuleDelegate { @Nullable private volatile WebClient webClient; @Nullable - private volatile InetSocketAddress serverAddress; + private volatile ServerPort serverPort; /** * Creates a new instance. @@ -127,14 +127,13 @@ public final CompletableFuture startAsync(File dataDir) { this.dogma = dogma0; return dogma0.start().thenRun(() -> { // A custom port may be added to the server during the configuration. - final ServerPort activePort = Iterables.getLast(dogma0.activePorts().values()); - if (activePort == null) { + final ServerPort serverPort = Iterables.getLast(dogma0.activePorts().values()); + if (serverPort == null) { // Stopped already. return; } - final InetSocketAddress serverAddress = activePort.localAddress(); - this.serverAddress = serverAddress; + this.serverPort = serverPort; final ArmeriaCentralDogmaBuilder clientBuilder = new ArmeriaCentralDogmaBuilder(); final LegacyCentralDogmaBuilder legacyClientBuilder = new LegacyCentralDogmaBuilder(); @@ -158,7 +157,8 @@ public final CompletableFuture startAsync(File dataDir) { throw new IOError(e); } - final String uri = "h2c://127.0.0.1:" + serverAddress.getPort(); + final String protocol = serverPort.hasHttp() ? "h2c" : "h2"; + final String uri = protocol + "://127.0.0.1:" + serverPort.localAddress().getPort(); final WebClientBuilder webClientBuilder = WebClient.builder(uri); if (accessToken != null) { webClientBuilder.auth(AuthToken.ofOAuth2(accessToken)); @@ -274,11 +274,11 @@ public final BlockingWebClient blockingHttpClient() { * @throws IllegalStateException if Central Dogma did not start yet */ public final InetSocketAddress serverAddress() { - final InetSocketAddress serverAddress = this.serverAddress; - if (serverAddress == null) { + final ServerPort serverPort = this.serverPort; + if (serverPort == null) { throw new IllegalStateException("Central Dogma not started"); } - return serverAddress; + return serverPort.localAddress(); } /** @@ -316,11 +316,12 @@ protected String accessToken() { protected void scaffold(CentralDogma client) {} private void configureClientCommon(AbstractArmeriaCentralDogmaBuilder builder) { - final InetSocketAddress serverAddress = this.serverAddress; - assert serverAddress != null; + final ServerPort serverPort = this.serverPort; + assert serverPort != null; + final InetSocketAddress serverAddress = serverPort.localAddress(); builder.host(serverAddress.getHostString(), serverAddress.getPort()); - if (useTls) { + if (useTls || (serverPort.protocols().size() == 1 && serverPort.hasHttps())) { builder.useTls(); builder.clientFactory(ClientFactory.insecure()); }