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 super HttpService, EncodingService> 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..96c7da8b90
--- /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, "management.port: %s (expected: 0-65535)", 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());
}