Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
import com.linecorp.centraldogma.common.Entry;
import com.linecorp.centraldogma.common.Query;
import com.linecorp.centraldogma.common.Revision;
import com.linecorp.centraldogma.internal.Jackson;
import com.linecorp.centraldogma.internal.Yaml;
import com.linecorp.centraldogma.server.storage.repository.Repository;
import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension;
import com.linecorp.centraldogma.xds.internal.XdsResourceManager;
Expand Down Expand Up @@ -147,7 +149,6 @@ void extractsNodeIpFromLabel() throws Exception {

final AggregatedHttpResponse response = createAggregator(aggregator, aggregatorId);
assertThat(response.status()).isSameAs(HttpStatus.OK);
assertThat(response.headers().get("grpc-status")).isEqualTo("0");

final Repository fooGroup = dogma.projectManager().get(INTERNAL_PROJECT_XDS)
.repos().get("foo");
Expand Down Expand Up @@ -223,7 +224,6 @@ void fallsBackToInternalIpWhenLabelKeyIsAbsent() throws Exception {

final AggregatedHttpResponse response = createAggregator(aggregator, aggregatorId);
assertThat(response.status()).isSameAs(HttpStatus.OK);
assertThat(response.headers().get("grpc-status")).isEqualTo("0");

final Repository fooGroup = dogma.projectManager().get(INTERNAL_PROJECT_XDS)
.repos().get("foo");
Expand Down Expand Up @@ -315,11 +315,12 @@ private static AggregatedHttpResponse createAggregator(
RequestHeaders.builder(HttpMethod.POST,
"/api/v1/xds/groups/foo/k8s/endpointAggregators?" +
"aggregator_id=" + aggregatorId)
.contentType(MediaType.JSON_UTF_8)
.contentType(MediaType.parse("application/yaml"))
.set(HttpHeaderNames.AUTHORIZATION, "Bearer anonymous")
.build();
return dogma.httpClient().blocking().execute(
headers, XdsResourceManager.JSON_MESSAGE_MARSHALLER.writeValueAsString(aggregator));
final String yaml = Yaml.writeValueAsString(
Jackson.readTree(XdsResourceManager.JSON_MESSAGE_MARSHALLER.writeValueAsString(aggregator)));
return dogma.httpClient().blocking().execute(headers, yaml);
}

private static Node newNodeWithLabel(String internalIp, String labelKey, String labelValue) {
Expand Down
1 change: 0 additions & 1 deletion webapp/src/dogma/features/xds/XdsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,6 @@ export const XDS_RESOURCE_TEMPLATES: Record<XdsResourceType, string> = {
},
},
],
respectDnsTtl: true,
},
null,
2,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@
import static com.linecorp.centraldogma.xds.internal.ControlPlaneService.CLUSTERS_DIRECTORY;
import static com.linecorp.centraldogma.xds.internal.XdsResourceManager.LEGACY_RESOURCE_ID_PATTERN_STRING;
import static com.linecorp.centraldogma.xds.internal.XdsResourceManager.RESOURCE_ID_PATTERN;
import static com.linecorp.centraldogma.xds.internal.XdsResourceManager.removePrefix;

import java.util.regex.Matcher;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Pattern;

import com.google.protobuf.Empty;
import org.jspecify.annotations.Nullable;

import com.linecorp.centraldogma.xds.cluster.v1.XdsClusterServiceGrpc.XdsClusterServiceImplBase;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.HttpStatus;
import com.linecorp.armeria.server.annotation.Consumes;
import com.linecorp.armeria.server.annotation.Delete;
import com.linecorp.armeria.server.annotation.Param;
import com.linecorp.armeria.server.annotation.Post;
import com.linecorp.armeria.server.annotation.Put;
import com.linecorp.centraldogma.common.RepositoryRole;
import com.linecorp.centraldogma.xds.internal.RequiresXdsGroupRole;
import com.linecorp.centraldogma.xds.internal.XdsResourceManager;

import io.envoyproxy.envoy.config.cluster.v3.Cluster;
import io.grpc.Status;
import io.grpc.stub.StreamObserver;

/**
* Service for managing clusters.
* Annotated service object for managing clusters.
*/
public final class XdsClusterService extends XdsClusterServiceImplBase {
public final class XdsClusterService {

private static final Pattern CLUSTER_NAME_PATTERN =
Pattern.compile("^groups/([^/]+)/clusters/" + LEGACY_RESOURCE_ID_PATTERN_STRING + '$');
Expand All @@ -51,69 +57,89 @@ public XdsClusterService(XdsResourceManager xdsResourceManager) {
this.xdsResourceManager = xdsResourceManager;
}

@Override
public void createCluster(CreateClusterRequest request, StreamObserver<Cluster> responseObserver) {
final String parent = request.getParent();
final String group = removePrefix("groups/", parent);
xdsResourceManager.checkWritePermission(group);

final String clusterId = request.getClusterId();
/**
* POST /xds/groups/{group}/clusters
*
* <p>Creates a new cluster.
*/
@Post("/xds/groups/{group}/clusters")
@Consumes("application/yaml")
@RequiresXdsGroupRole(RepositoryRole.WRITE)
public CompletableFuture<HttpResponse> createCluster(
@Param("group") String group,
@Param("cluster_id") String clusterId,
@Param("summary") @Nullable String summary,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A summary seems like an arbitrary value set by users. Is it safe to set in the query parameters? I'm concerned about potential issues related to encoding or string length.

@minwoox minwoox Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it safe to set in the query parameters? I'm concerned about potential issues related to encoding or string length.

Yeah, it's going to be encoded. Also the length limit is more than 2000 chars so I think it should be fine.
https://medium.com/suyeonme/effective-strategies-for-handling-long-query-strings-b790e1fddd65

Let's revisit this if this becomes an issue. 😉

String body) {
if (!RESOURCE_ID_PATTERN.matcher(clusterId).matches()) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid cluster_id: " + clusterId +
" (expected: " + RESOURCE_ID_PATTERN + ')')
.asRuntimeException();
return CompletableFuture.completedFuture(
XdsResourceManager.errorResponse(HttpStatus.BAD_REQUEST,
"Invalid cluster ID: " + clusterId));
}

final String clusterName = parent + CLUSTERS_DIRECTORY + clusterId;
final Cluster cluster
= request.getCluster()
.toBuilder()
// Ignore the specified name in the cluster and set the name with the format of
// "groups/{group}/clusters/{cluster}".
// https://github.com/aip-dev/google.aip.dev/blob/master/aip/general/0133.md#user-specified-ids
.setName(clusterName)
// Respect the DNS TTL would be more efficient in terms of DNS resolution.
// https://github.com/envoyproxy/envoy/issues/6876
// `respect_dns_ttl` is a `bool` field so it is not possible to check whether a value
// has not been set for the field. Until we create our own proto file, the value only
// can be set to false via the update API.
.setRespectDnsTtl(true)
.build();
final String createSummary = isNullOrEmpty(request.getSummary()) ?
"Create cluster: " + clusterName : request.getSummary();
xdsResourceManager.push(responseObserver, group, clusterName, CLUSTERS_DIRECTORY + clusterId + ".yaml",
createSummary, cluster, currentAuthor(), true);
}

@Override
public void updateCluster(UpdateClusterRequest request, StreamObserver<Cluster> responseObserver) {
final Cluster cluster = request.getCluster();
final String clusterName = cluster.getName();
final String group = checkClusterName(clusterName).group(1);
xdsResourceManager.checkWritePermission(group);
final String updateSummary = isNullOrEmpty(request.getSummary()) ?
"Update cluster: " + clusterName : request.getSummary();
xdsResourceManager.update(responseObserver, group, clusterName,
updateSummary, cluster, currentAuthor());
final String clusterName = "groups/" + group + CLUSTERS_DIRECTORY + clusterId;
try {
XdsResourceManager.parseYaml(body, Cluster.newBuilder());
} catch (IOException e) {
return CompletableFuture.completedFuture(
XdsResourceManager.errorResponse(HttpStatus.BAD_REQUEST,
"Invalid request body: " + e.getMessage()));
}
final String createSummary = isNullOrEmpty(summary) ? "Create cluster: " + clusterName : summary;
final String normalizedBody = XdsResourceManager.normalizeYamlKeys(body);
final String bodyToStore = XdsResourceManager.injectYamlField(normalizedBody, "name", clusterName);
return xdsResourceManager.push(group, clusterName, CLUSTERS_DIRECTORY + clusterId + ".yaml",
createSummary, currentAuthor(), true, bodyToStore);
}

@Override
public void deleteCluster(DeleteClusterRequest request, StreamObserver<Empty> responseObserver) {
final String clusterName = request.getName();
final String group = checkClusterName(clusterName).group(1);
xdsResourceManager.checkWritePermission(group);
final String deleteSummary = isNullOrEmpty(request.getSummary()) ?
"Delete cluster: " + clusterName : request.getSummary();
xdsResourceManager.delete(responseObserver, group, clusterName, deleteSummary, currentAuthor());
/**
* PUT /xds/groups/{group}/clusters/{cluster_id}
*
* <p>Updates an existing cluster.
*/
@Put("/xds/groups/{group}/clusters/{*cluster_id}")
@Consumes("application/yaml")
@RequiresXdsGroupRole(RepositoryRole.WRITE)
public CompletableFuture<HttpResponse> updateCluster(
@Param("group") String group,
@Param("cluster_id") String clusterId,
@Param("summary") @Nullable String summary,
String body) {
final String clusterName = "groups/" + group + "/clusters/" + clusterId;
if (!CLUSTER_NAME_PATTERN.matcher(clusterName).matches()) {
return CompletableFuture.completedFuture(
XdsResourceManager.errorResponse(HttpStatus.BAD_REQUEST,
"Invalid cluster name: " + clusterName));
}
try {
XdsResourceManager.parseYaml(body, Cluster.newBuilder());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question) Would it make sense to fork ProtobufRequestConverterFunction so that YAML request-body parsing can be handled as a cross-cutting concern?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's not so much gain for forking the class. If you think, this will be useful, let's consider adding yaml support to the ProtobufRequestConverterFunction on upstream.

} catch (IOException e) {
return CompletableFuture.completedFuture(
XdsResourceManager.errorResponse(HttpStatus.BAD_REQUEST,
"Invalid request body: " + e.getMessage()));
}
final String updateSummary = isNullOrEmpty(summary) ? "Update cluster: " + clusterName : summary;
final String normalizedBody = XdsResourceManager.normalizeYamlKeys(body);
final String bodyToStore = XdsResourceManager.injectYamlField(normalizedBody, "name", clusterName);
return xdsResourceManager.update(group, clusterName, updateSummary, currentAuthor(), bodyToStore);
}

private static Matcher checkClusterName(String clusterName) {
final Matcher matcher = CLUSTER_NAME_PATTERN.matcher(clusterName);
if (!matcher.matches()) {
throw Status.INVALID_ARGUMENT.withDescription("Invalid cluster name: " + clusterName +
" (expected: " + CLUSTER_NAME_PATTERN + ')')
.asRuntimeException();
/**
* DELETE /xds/groups/{group}/clusters/{cluster_id}
*
* <p>Removes a cluster.
*/
@Delete("/xds/groups/{group}/clusters/{*cluster_id}")
@RequiresXdsGroupRole(RepositoryRole.WRITE)
public CompletableFuture<HttpResponse> deleteCluster(
@Param("group") String group,
@Param("cluster_id") String clusterId,
@Param("summary") @Nullable String summary) {
final String clusterName = "groups/" + group + "/clusters/" + clusterId;
if (!CLUSTER_NAME_PATTERN.matcher(clusterName).matches()) {
return CompletableFuture.completedFuture(
XdsResourceManager.errorResponse(HttpStatus.BAD_REQUEST,
"Invalid cluster name: " + clusterName));
}
return matcher;
final String deleteSummary = isNullOrEmpty(summary) ? "Delete cluster: " + clusterName : summary;
return xdsResourceManager.delete(group, clusterName, deleteSummary, currentAuthor());
}
}
Loading
Loading