diff --git a/client/java-armeria-xds/build.gradle b/client/java-armeria-xds/build.gradle index f342d9a25..4dc272ffe 100644 --- a/client/java-armeria-xds/build.gradle +++ b/client/java-armeria-xds/build.gradle @@ -1,5 +1,6 @@ dependencies { api project(':client:java-armeria') + api project(':xds-api') // Armeria api libs.armeria.xds diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaExtensionFactoryProvider.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaExtensionFactoryProvider.java new file mode 100644 index 000000000..db3352250 --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaExtensionFactoryProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.client.armeria.xds.configsource; + +import com.linecorp.armeria.xds.XdsExtensionFactory; +import com.linecorp.armeria.xds.XdsExtensionFactoryProvider; + +/** + * An {@link XdsExtensionFactoryProvider} that provides a + * {@link CentralDogmaSotwConfigSourceSubscriptionFactory}. + */ +public final class CentralDogmaExtensionFactoryProvider implements XdsExtensionFactoryProvider { + + @Override + public XdsExtensionFactory newFactory() { + return new CentralDogmaSotwConfigSourceSubscriptionFactory(); + } +} diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaSotwConfigSourceSubscriptionFactory.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaSotwConfigSourceSubscriptionFactory.java new file mode 100644 index 000000000..634c74eb4 --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaSotwConfigSourceSubscriptionFactory.java @@ -0,0 +1,229 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.client.armeria.xds.configsource; + +import static com.google.common.base.Preconditions.checkArgument; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.protobuf.Any; + +import com.linecorp.armeria.common.util.Exceptions; +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.GenericSecretSnapshot; +import com.linecorp.armeria.xds.SnapshotWatcher; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.armeria.xds.XdsType; +import com.linecorp.armeria.xds.configsource.InterestedResources; +import com.linecorp.armeria.xds.configsource.SotwConfigSourceSubscriptionFactory; +import com.linecorp.armeria.xds.filter.FactoryContext; +import com.linecorp.armeria.xds.stream.RefCountedStream; +import com.linecorp.armeria.xds.stream.SnapshotStream; +import com.linecorp.armeria.xds.stream.Subscription; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.client.Watcher; +import com.linecorp.centraldogma.client.WatcherRequest; +import com.linecorp.centraldogma.common.Query; +import com.linecorp.centraldogma.internal.CsrfToken; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.xds.v1.CentralDogmaConfigSource; + +import io.envoyproxy.envoy.config.core.v3.ConfigSource; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig; +import io.envoyproxy.envoy.service.discovery.v3.DiscoveryResponse; +import io.netty.util.concurrent.EventExecutor; + +final class CentralDogmaSotwConfigSourceSubscriptionFactory + implements SotwConfigSourceSubscriptionFactory { + + static final String NAME = "centraldogma.config_source"; + static final String TYPE_URL = + "type.googleapis.com/com.linecorp.centraldogma.xds.v1.CentralDogmaConfigSource"; + + @Override + public String name() { + return NAME; + } + + @Override + public List typeUrls() { + return ImmutableList.of(TYPE_URL); + } + + @Override + public SnapshotStream create(ConfigSource configSource, + FactoryContext factoryContext, + SnapshotStream interestedResources) { + final CentralDogmaConfigSource cdConfig = + factoryContext.validator().unpack( + configSource.getCustomConfigSource().getTypedConfig(), + CentralDogmaConfigSource.class); + checkArgument(!cdConfig.getClusterName().isEmpty(), + "CentralDogmaConfigSource.cluster_name must not be empty"); + final SnapshotStream clusterStream = + factoryContext.clusterStream(cdConfig.getClusterName()); + final EventExecutor eventLoop = factoryContext.eventLoop(); + final SnapshotStream accessTokenStream; + if (cdConfig.hasBearerTokenCredential()) { + final SdsSecretConfig tokenSecret = cdConfig.getBearerTokenCredential().getTokenSecret(); + accessTokenStream = factoryContext.genericSecretStream(tokenSecret) + .map(GenericSecretSnapshot::credential); + } else { + accessTokenStream = SnapshotStream.just(CsrfToken.ANONYMOUS); + } + final SnapshotStream cdStream = + SnapshotStream.combineLatest(clusterStream, accessTokenStream, Map::entry) + .switchMapEager(entry -> { + return new CentralDogmaClientStream(entry.getKey(), entry.getValue(), + factoryContext); + }); + return cdStream.switchMapEager(centralDogma -> { + final Map accumulated = new EnumMap<>(XdsType.class); + final Function> watcherCache = + SnapshotStream.caching( + name -> new WatcherStream(centralDogma, ResourcePath.parse(name)) + .rescheduleEventsOn(eventLoop)); + return interestedResources + .map(interest -> { + accumulated.put(interest.type(), interest); + return ImmutableMap.copyOf(accumulated); + }) + .switchMapEager(interests -> + new Interest2ResponsesStream(interests, watcherCache)); + }); + } + + private static final class Interest2ResponsesStream extends RefCountedStream { + + private final Map interests; + private final Function> watcherCache; + + Interest2ResponsesStream(Map interests, + Function> watcherCache) { + this.interests = interests; + this.watcherCache = watcherCache; + } + + @Override + protected Subscription onStart(SnapshotWatcher watcher) { + final List subs = new ArrayList<>(); + for (InterestedResources interested : interests.values()) { + final String typeUrl = interested.type().typeUrl(); + final List> streams = + interested.resourceNames().stream() + .map(name -> watcherCache.apply(name) + .map(jsonNode -> toAny(jsonNode, typeUrl))) + .collect(ImmutableList.toImmutableList()); + subs.add(SnapshotStream.combineNLatest(streams) + .map(resources -> DiscoveryResponse.newBuilder() + .setTypeUrl(typeUrl) + .addAllResources(resources) + .build()) + .subscribe(this::emit)); + } + return () -> { + subs.forEach(Subscription::close); + subs.clear(); + }; + } + + private static Any toAny(JsonNode jsonNode, String typeUrl) { + ((ObjectNode) jsonNode).put("@type", typeUrl); + return XdsResourceReader.from(jsonNode.toString(), Any.class); + } + } + + private static final class CentralDogmaClientStream extends RefCountedStream { + + private final ClusterSnapshot clusterSnapshot; + private final String accessToken; + private final FactoryContext factoryContext; + + CentralDogmaClientStream(ClusterSnapshot clusterSnapshot, String accessToken, + FactoryContext factoryContext) { + this.clusterSnapshot = clusterSnapshot; + this.accessToken = accessToken; + this.factoryContext = factoryContext; + } + + @Override + protected Subscription onStart(SnapshotWatcher watcher) { + final CentralDogma centralDogma = PreprocessorBasedCentralDogma.of( + clusterSnapshot.preprocessor(), accessToken, factoryContext.meterRegistry()); + emit(centralDogma, null); + return () -> { + try { + centralDogma.close(); + } catch (Exception e) { + Exceptions.throwUnsafely(e); + } + }; + } + } + + private static final class WatcherStream extends RefCountedStream { + + private final CentralDogma centralDogma; + private final ResourcePath resourcePath; + + WatcherStream(CentralDogma centralDogma, ResourcePath resourcePath) { + this.centralDogma = centralDogma; + this.resourcePath = resourcePath; + } + + @Override + protected Subscription onStart(SnapshotWatcher watcher) { + final Watcher cdWatcher; + if (resourcePath.isFtl()) { + // .ftl files are stored as TEXT, so we use Query.ofText and parse after rendering. + final WatcherRequest textRequest = + centralDogma.forRepo(resourcePath.project(), resourcePath.repo()) + .watcher(Query.ofText(resourcePath.path())); + textRequest.renderTemplate(true); + if (resourcePath.profile() != null) { + textRequest.renderTemplate(resourcePath.profile()); + } + final Watcher textWatcher = textRequest.start(); + textWatcher.watch((revision, text) -> emitText(text)); + cdWatcher = textWatcher; + } else { + final WatcherRequest jsonRequest = + centralDogma.forRepo(resourcePath.project(), resourcePath.repo()) + .watcher(resourcePath.query()); + final Watcher jsonWatcher = jsonRequest.start(); + jsonWatcher.watch((revision, jsonNode) -> emit(jsonNode, null)); + cdWatcher = jsonWatcher; + } + return cdWatcher::close; + } + + private void emitText(String text) { + try { + emit(Jackson.readTree(resourcePath.basePath(), text), null); + } catch (Exception e) { + emit(null, e); + } + } + } +} diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaTypeRegistryPackageProvider.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaTypeRegistryPackageProvider.java new file mode 100644 index 000000000..af06fbb34 --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/CentralDogmaTypeRegistryPackageProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.client.armeria.xds.configsource; + +import java.util.List; + +import com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider; + +/** + * An {@link XdsTypeRegistryPackageProvider} that registers the Central Dogma xDS protobuf types. + */ +public final class CentralDogmaTypeRegistryPackageProvider implements XdsTypeRegistryPackageProvider { + + @Override + public Iterable packages() { + return List.of("com.linecorp.centraldogma.xds"); + } +} diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/PreprocessorBasedCentralDogma.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/PreprocessorBasedCentralDogma.java new file mode 100644 index 000000000..515557ca2 --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/PreprocessorBasedCentralDogma.java @@ -0,0 +1,50 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.client.armeria.xds.configsource; + +import static java.util.Objects.requireNonNull; + +import com.linecorp.armeria.client.ClientBuilder; +import com.linecorp.armeria.client.ClientPreprocessors; +import com.linecorp.armeria.client.Clients; +import com.linecorp.armeria.client.HttpPreprocessor; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.encoding.DecodingClient; +import com.linecorp.armeria.common.CommonPools; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.internal.client.armeria.ArmeriaCentralDogma; + +import io.micrometer.core.instrument.MeterRegistry; + +/** + * Creates a {@link CentralDogma} client that connects through an {@link HttpPreprocessor}. + */ +final class PreprocessorBasedCentralDogma { + + static CentralDogma of(HttpPreprocessor preprocessor, String accessToken, + MeterRegistry meterRegistry) { + requireNonNull(preprocessor, "preprocessor"); + requireNonNull(accessToken, "accessToken"); + final ClientBuilder builder = + Clients.builder(ClientPreprocessors.of(preprocessor)); + builder.decorator(DecodingClient.newDecorator()); + final WebClient client = builder.build(WebClient.class); + return new ArmeriaCentralDogma(CommonPools.blockingTaskExecutor(), client, accessToken, + () -> {}, meterRegistry, null); + } + + private PreprocessorBasedCentralDogma() {} +} diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/ResourcePath.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/ResourcePath.java new file mode 100644 index 000000000..bbd9945bf --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/ResourcePath.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.client.armeria.xds.configsource; + +import static com.google.common.base.Preconditions.checkArgument; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; + +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.centraldogma.common.Query; + +/** + * Parses a resource name encoded as {@code {project}/{repo}/{path}} into its + * constituent parts. The first two segments are the Central Dogma project and + * repository name, and the remainder (prefixed with {@code /}) is the file path. + * + *

An optional {@code ?profile=} query parameter can be appended to specify + * a per-resource variable file (profile) for template rendering. + * + *

For example, {@code "myproject/myrepo/clusters/my-cluster.json.ftl?profile=/vars/x.yaml"} + * is parsed as: + *

    + *
  • project: {@code "myproject"}
  • + *
  • repo: {@code "myrepo"}
  • + *
  • path: {@code "/clusters/my-cluster.json.ftl"}
  • + *
  • profile: {@code "/vars/x.yaml"}
  • + *
  • ftl: {@code true}
  • + *
+ */ +final class ResourcePath { + + private final String project; + private final String repo; + private final String path; + @Nullable + private final String profile; + private final boolean ftl; + + static ResourcePath parse(String resourceName) { + // Split off query string if present. + String name = resourceName; + String profile = null; + final int questionMark = resourceName.indexOf('?'); + if (questionMark >= 0) { + final String queryString = resourceName.substring(questionMark + 1); + name = resourceName.substring(0, questionMark); + final QueryParams params = QueryParams.fromQueryString(queryString); + profile = params.get("profile"); + checkArgument(profile == null || !profile.isEmpty(), + "Invalid resource name (empty profile): %s", resourceName); + } + + final int firstSlash = name.indexOf('/'); + checkArgument(firstSlash > 0, "Invalid resource name (missing project): %s", resourceName); + final int secondSlash = name.indexOf('/', firstSlash + 1); + checkArgument(secondSlash > firstSlash + 1, + "Invalid resource name (missing repo): %s", resourceName); + checkArgument(secondSlash < name.length() - 1, + "Invalid resource name (missing path): %s", resourceName); + + final String project = name.substring(0, firstSlash); + final String repo = name.substring(firstSlash + 1, secondSlash); + final String filePath = '/' + name.substring(secondSlash + 1); + + final boolean ftl = filePath.endsWith(".ftl"); + + return new ResourcePath(project, repo, filePath, profile, ftl); + } + + private ResourcePath(String project, String repo, String path, + @Nullable String profile, boolean ftl) { + this.project = project; + this.repo = repo; + this.path = path; + this.profile = profile; + this.ftl = ftl; + } + + String project() { + return project; + } + + String repo() { + return repo; + } + + /** + * Returns the file path within the repository (e.g., {@code "/clusters/my-cluster.json.ftl"}). + */ + String path() { + return path; + } + + /** + * Returns the profile path if specified via {@code ?profile=}, or {@code null}. + */ + @Nullable + String profile() { + return profile; + } + + /** + * Returns {@code true} if the file path ends with {@code .ftl}, indicating template rendering is needed. + */ + boolean isFtl() { + return ftl; + } + + /** + * Returns the path without the {@code .ftl} suffix, so Jackson dispatches to the correct parser + * (e.g., {@code .json.ftl} → {@code .json}, {@code .yaml.ftl} → {@code .yaml}). + * + * @throws IllegalStateException if this is not an ftl resource + */ + String basePath() { + if (!ftl) { + throw new IllegalStateException("Not an ftl resource: " + path); + } + return path.substring(0, path.length() - 4); + } + + /** + * Returns the appropriate {@link Query} based on the file extension. + * Only used for non-ftl files. For ftl files, {@link Query#ofText(String)} is used instead. + *
    + *
  • {@code .json} → {@link Query#ofJson(String)}
  • + *
  • {@code .yaml}, {@code .yml} → {@link Query#ofYaml(String)}
  • + *
+ */ + Query query() { + if (ftl) { + throw new IllegalStateException("Use Query.ofText() for ftl resources: " + path); + } + if (path.endsWith(".yaml") || path.endsWith(".yml")) { + return Query.ofYaml(path); + } + return Query.ofJson(path); + } +} diff --git a/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/package-info.java b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/package-info.java new file mode 100644 index 000000000..5a0d51366 --- /dev/null +++ b/client/java-armeria-xds/src/main/java/com/linecorp/centraldogma/client/armeria/xds/configsource/package-info.java @@ -0,0 +1,23 @@ +/* + * 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. + */ +/** + * Armeria's xDS-based Central Dogma client implementation. + * @see Java client library + */ +@NullMarked +package com.linecorp.centraldogma.client.armeria.xds.configsource; + +import org.jspecify.annotations.NullMarked; diff --git a/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider b/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider new file mode 100644 index 000000000..3286e8447 --- /dev/null +++ b/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsExtensionFactoryProvider @@ -0,0 +1 @@ +com.linecorp.centraldogma.client.armeria.xds.configsource.CentralDogmaExtensionFactoryProvider diff --git a/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider b/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider new file mode 100644 index 000000000..51a023842 --- /dev/null +++ b/client/java-armeria-xds/src/main/resources/META-INF/services/com.linecorp.armeria.xds.XdsTypeRegistryPackageProvider @@ -0,0 +1 @@ +com.linecorp.centraldogma.client.armeria.xds.configsource.CentralDogmaTypeRegistryPackageProvider diff --git a/client/java-armeria-xds/src/test/resources/test-listener.yaml b/client/java-armeria-xds/src/test/resources/test-listener.yaml index 0fe0cde50..6bf0c2fc6 100644 --- a/client/java-armeria-xds/src/test/resources/test-listener.yaml +++ b/client/java-armeria-xds/src/test/resources/test-listener.yaml @@ -1,15 +1,8 @@ name: -address: - socket_address: - address: 0.0.0.0 - port_value: 8080 api_listener: api_listener: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http - http_protocol_options: - enable_trailers: true - codec_type: AUTO route_config: name: local_route virtual_hosts: diff --git a/dependencies.toml b/dependencies.toml index 1306af520..bfcfec161 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -3,7 +3,7 @@ # If its classes are exposed in Javadoc, update offline links as well. # [versions] -armeria = "1.40.0" +armeria = "1.41.0" assertj = "3.27.7" awaitility = "4.3.0" bouncycastle = "1.84" @@ -392,6 +392,9 @@ version.ref = "owasp" module = "com.guardsquare:proguard-gradle" version.ref = "proguard" +[libraries.protobuf-java] +module = "com.google.protobuf:protobuf-java" +version.ref = "protobuf" [libraries.protobuf-protoc] module = "com.google.protobuf:protoc" version.ref = "protobuf" diff --git a/it/xds-client/build.gradle b/it/xds-client/build.gradle new file mode 100644 index 000000000..02c638621 --- /dev/null +++ b/it/xds-client/build.gradle @@ -0,0 +1,8 @@ +dependencies { + testImplementation project(':client:java-armeria-xds') + testImplementation project(':server') + testImplementation project(':testing:junit') + + testImplementation libs.armeria.junit5 + testImplementation libs.jackson.dataformat.yaml +} diff --git a/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceBearerTokenTest.java b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceBearerTokenTest.java new file mode 100644 index 000000000..cc43bd107 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceBearerTokenTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.xds.it; + +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +class CentralDogmaConfigSourceBearerTokenTest { + + //language=JSON + private static final String CLUSTER_JSON = """ + { + "name": "test/xds/clusters/my-cluster.json", + "type": "STATIC", + "load_assignment": { + "cluster_name": "test/xds/clusters/my-cluster.json", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": 9999 + } + } + } + } + ] + } + ] + } + } + """; + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.systemAdministrators(USERNAME); + builder.authProviderFactory(new TestAuthProviderFactory()); + } + + @Override + protected String accessToken() { + return getAccessToken( + WebClient.of("http://127.0.0.1:" + dogma.serverAddress().getPort()), + USERNAME, PASSWORD, true); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject("test").join(); + client.createRepository("test", "xds").join(); + client.forRepo("test", "xds") + .commit("Add cluster", + Change.ofJsonUpsert("/clusters/my-cluster.json", CLUSTER_JSON)) + .push() + .join(); + } + }; + + @Test + void fetchClusterWithBearerToken() { + final String appToken = getAccessToken(dogma.httpClient(), + USERNAME, PASSWORD, "xdsAppId", true); + final int port = dogma.serverAddress().getPort(); + //language=YAML + final String yaml = """ + static_resources: + clusters: + - name: centraldogma-server + type: STATIC + load_assignment: + cluster_name: centraldogma-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %d + secrets: + - name: centraldogma_token + generic_secret: + secret: + inline_string: "%s" + dynamic_resources: + cds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + bearer_token_credential: + token_secret: + name: centraldogma_token + """.formatted(port, appToken); + + final Bootstrap bootstrap = XdsResourceReader.from(yaml, Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot) { + snapshotRef.set((ClusterSnapshot) snapshot); + } + }) + .build()) { + xdsBootstrap.clusterRoot("test/xds/clusters/my-cluster.json"); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("test/xds/clusters/my-cluster.json"); + assertThat(snapshotRef.get().xdsResource().resource() + .getLoadAssignment() + .getEndpoints(0).getLbEndpoints(0) + .getEndpoint().getAddress().getSocketAddress() + .getPortValue()) + .isEqualTo(9999); + }); + } + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceMtlsTest.java b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceMtlsTest.java new file mode 100644 index 000000000..26a3be862 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceMtlsTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.xds.it; + +import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V1_PATH_PREFIX; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.PASSWORD; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.USERNAME; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getAccessToken; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.ClientTlsConfig; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.SessionProtocol; +import com.linecorp.armeria.common.TlsKeyPair; +import com.linecorp.armeria.common.TlsProvider; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.SignedCertificateExtension; +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.common.ProjectRole; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.TlsConfig; +import com.linecorp.centraldogma.server.auth.MtlsConfig; +import com.linecorp.centraldogma.server.internal.api.MetadataApiService.IdAndProjectRole; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +class CentralDogmaConfigSourceMtlsTest { + + private static final String CERT_ID = "my-client"; + + @Order(1) + @RegisterExtension + static final SelfSignedCertificateExtension serverCert = new SelfSignedCertificateExtension(); + + @Order(2) + @RegisterExtension + static final SelfSignedCertificateExtension ca = new SelfSignedCertificateExtension(); + + @Order(3) + @RegisterExtension + static final SignedCertificateExtension clientCert = + new SignedCertificateExtension(CERT_ID, ca, false); + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.authProviderFactory(new TestAuthProviderFactory()); + builder.port(0, SessionProtocol.HTTPS); + builder.tls(new TlsConfig(serverCert.certificateFile(), serverCert.privateKeyFile(), + null, null, null)); + builder.mtlsConfig(new MtlsConfig(true, ImmutableList.of(ca.certificateFile()))); + builder.systemAdministrators(USERNAME); + } + + @Override + protected String accessToken() { + final WebClient client = webClient(); + return getAccessToken(client, USERNAME, PASSWORD, "testId", true, true, false); + } + + @Override + protected void configureHttpClient(WebClientBuilder builder) { + configureWebClientBuilder(builder); + } + + @Override + protected void scaffold(CentralDogma client) { + client.createProject("test").join(); + client.createRepository("test", "xds").join(); + client.forRepo("test", "xds") + .commit("Add cluster", + Change.ofJsonUpsert("/clusters/my-cluster.json", CLUSTER_JSON)) + .push() + .join(); + } + }; + + //language=JSON + private static final String CLUSTER_JSON = """ + { + "name": "test/xds/clusters/my-cluster.json", + "type": "STATIC", + "load_assignment": { + "cluster_name": "test/xds/clusters/my-cluster.json", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": 9999 + } + } + } + } + ] + } + ] + } + } + """; + + private static void configureWebClientBuilder(WebClientBuilder builder) { + final TlsKeyPair tlsKeyPair = TlsKeyPair.of(clientCert.privateKey(), + clientCert.certificate()); + final ClientTlsConfig tlsConfig = + ClientTlsConfig.builder() + .tlsCustomizer(b -> b.trustManager(serverCert.certificate())) + .build(); + builder.factory(ClientFactory.builder() + .tlsProvider(TlsProvider.of(tlsKeyPair), tlsConfig) + .build()); + } + + private static WebClient webClient() { + final TlsKeyPair tlsKeyPair = TlsKeyPair.of(clientCert.privateKey(), + clientCert.certificate()); + final ClientTlsConfig tlsConfig = + ClientTlsConfig.builder() + .tlsCustomizer(b -> b.trustManager(serverCert.certificate())) + .build(); + return WebClient.builder("https://127.0.0.1:" + dogma.serverAddress().getPort()) + .factory(ClientFactory.builder() + .tlsProvider(TlsProvider.of(tlsKeyPair), tlsConfig) + .build()) + .build(); + } + + @Test + void fetchClusterWithMtls() throws Exception { + // Register the client certificate as an app identity. + assertThat(dogma.httpClient() + .post(API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "cert1", + "type", "CERTIFICATE", + "certificateId", CERT_ID, + "isSystemAdmin", false), + HttpData.empty()) + .aggregate().join().status()) + .isEqualTo(HttpStatus.CREATED); + + // Grant the cert identity access to the 'test' project. + final HttpRequest grantRequest = HttpRequest.builder() + .post("/api/v1/metadata/test/appIdentities") + .contentJson(new IdAndProjectRole("cert1", + ProjectRole.MEMBER)) + .build(); + assertThat(dogma.httpClient().execute(grantRequest).aggregate().join().status()) + .isSameAs(HttpStatus.OK); + + final int port = dogma.serverAddress().getPort(); + //language=YAML + final String yaml = """ + static_resources: + clusters: + - name: centraldogma-server + type: STATIC + load_assignment: + cluster_name: centraldogma-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %d + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions\ + .transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificates: + - certificate_chain: + filename: %s + private_key: + filename: %s + validation_context: + trusted_ca: + filename: %s + dynamic_resources: + cds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + """.formatted(port, + clientCert.certificateFile().getAbsolutePath(), + clientCert.privateKeyFile().getAbsolutePath(), + serverCert.certificateFile().getAbsolutePath()); + + final Bootstrap bootstrap = XdsResourceReader.from(yaml, Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot) { + snapshotRef.set((ClusterSnapshot) snapshot); + } + }) + .build()) { + xdsBootstrap.clusterRoot("test/xds/clusters/my-cluster.json"); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("test/xds/clusters/my-cluster.json"); + assertThat(snapshotRef.get().xdsResource().resource() + .getLoadAssignment() + .getEndpoints(0).getLbEndpoints(0) + .getEndpoint().getAddress().getSocketAddress() + .getPortValue()) + .isEqualTo(9999); + }); + } + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTemplateTest.java b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTemplateTest.java new file mode 100644 index 000000000..870a32e77 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTemplateTest.java @@ -0,0 +1,460 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.xds.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.ListenerRoot; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.config.cluster.v3.Cluster; + +class CentralDogmaConfigSourceTemplateTest { + + // FreeMarker snippet that constructs the full xDS resource name from centraldogma context. + // Includes the file extension and ?profile= when present. + private static final String RESOURCE_NAME = + "${centraldogma.project}/${centraldogma.repo}${centraldogma.path}" + + "<#if centraldogma.variableFile??>?profile=${centraldogma.variableFile}"; + + // An EDS cluster template — name, type, and edsClusterConfig are set automatically. + // Any additional cluster fields (e.g., transportSocket) can be specified directly in vars. + private static final String EDS_CLUSTER_JSON_TEMPLATE = """ + { + "name": "%s", + "type": "EDS", + "eds_cluster_config": { + "service_name": "${vars.service_name}", + "eds_config": { + "custom_config_source": { + "name": "centraldogma.config_source", + "typed_config": { + "@type": "type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource", + "cluster_name": "centraldogma-server" + } + } + } + }<#list vars as key, value><#if key != "service_name">, + "${key}": ${toJson(value)} + } + """.formatted(RESOURCE_NAME); + + // A listener template — each route object is serialized transparently via ${toJson(r)}, + // so any field on config.route.v3.Route is supported without template changes. + private static final String LISTENER_JSON_TEMPLATE = """ + { + "name": "%1$s", + "api_listener": { + "api_listener": { + "@type": "type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager", + "stat_prefix": "http", + "route_config": { + "name": "%1$s", + "virtual_hosts": [{ + "name": "default", + "domains": ["*"], + "routes": [ + <#list vars.routes as r> + ${toJson(r)}<#if r?has_next>, + + ] + }] + }, + "http_filters": [{ + "name": "envoy.filters.http.router", + "typed_config": { + "@type": "type.googleapis.com/envoy.extensions.filters.http\ + .router.v3.Router" + } + }] + } + } + } + """.formatted(RESOURCE_NAME); + + // YAML variant of the EDS cluster template. + private static final String EDS_CLUSTER_YAML_TEMPLATE = """ + name: %s + type: EDS + eds_cluster_config: + service_name: ${vars.service_name} + eds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + <#list vars as key, value><#if key != "service_name"> + ${key}: ${toJson(value)} + + """.formatted(RESOURCE_NAME); + + // YAML variant of the listener template. + private static final String LISTENER_YAML_TEMPLATE = """ + name: %1$s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: http + route_config: + name: %1$s + virtual_hosts: + - name: default + domains: + - "*" + routes: + <#list vars.routes as r> + - ${toJson(r)} + + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http\ + .router.v3.Router + """.formatted(RESOURCE_NAME); + + //language=JSON + private static final String ENDPOINT_JSON = """ + { + "cluster_name": "test/xds/endpoints/my-endpoint.json", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": 7777 + } + } + } + } + ] + } + ] + } + """; + + // Profile that provides the serviceName variable for the EDS cluster template. + private static final String EDS_CONFIG_PROFILE = """ + service_name: test/xds/endpoints/my-endpoint.json + connect_timeout: 5s + """; + + private static final String EDS_CONFIG_PROFILE_PATH = "/xds/eds-config.yaml"; + + private static String clusterResourceName(String ext) { + return "test/xds/clusters/default-outbound." + ext + + ".ftl?profile=" + EDS_CONFIG_PROFILE_PATH; + } + + private static String routesYaml(String clusterExt, int routeCount) { + final String cluster = clusterResourceName(clusterExt); + if (routeCount == 2) { + return """ + routes: + - match: + prefix: /api + headers: + - name: x-api-version + string_match: + exact: v2 + route: + cluster: %1$s + - match: + prefix: / + route: + cluster: %1$s + """.formatted(cluster); + } + return """ + routes: + - match: + prefix: / + route: + cluster: %s + """.formatted(cluster); + } + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + @Override + protected void scaffold(CentralDogma client) { + client.createProject("test").join(); + client.createRepository("test", "xds").join(); + } + }; + + private static String bootstrapYaml() { + final int port = dogma.serverAddress().getPort(); + //language=YAML + return """ + static_resources: + clusters: + - name: centraldogma-server + type: STATIC + load_assignment: + cluster_name: centraldogma-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %d + dynamic_resources: + cds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + lds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + """.formatted(port); + } + + static Stream clusterTemplateArgs() { + return Stream.of( + Arguments.of("json", EDS_CLUSTER_JSON_TEMPLATE), + Arguments.of("yaml", EDS_CLUSTER_YAML_TEMPLATE) + ); + } + + @ParameterizedTest + @MethodSource("clusterTemplateArgs") + void templateCluster(String ext, String clusterTemplate) { + dogma.client().forRepo("test", "xds") + .commit("Add " + ext + " cluster template and endpoint", + Change.ofTextUpsert("/clusters/default-outbound." + ext + ".ftl", clusterTemplate), + Change.ofJsonUpsert("/endpoints/my-endpoint.json", ENDPOINT_JSON), + Change.ofTextUpsert(EDS_CONFIG_PROFILE_PATH, EDS_CONFIG_PROFILE)) + .push() + .join(); + + final String clusterName = clusterResourceName(ext); + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot) { + snapshotRef.set((ClusterSnapshot) snapshot); + } + }) + .build()) { + xdsBootstrap.clusterRoot(clusterName); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + final ClusterSnapshot cs = snapshotRef.get(); + assertThat(cs).isNotNull(); + final Cluster cluster = cs.xdsResource().resource(); + assertThat(cluster.getName()).isEqualTo(clusterName); + assertThat(cluster.getType()).isEqualTo(Cluster.DiscoveryType.EDS); + assertThat(cluster.getEdsClusterConfig().getServiceName()) + .isEqualTo("test/xds/endpoints/my-endpoint.json"); + assertThat(cluster.getConnectTimeout().getSeconds()).isEqualTo(5); + assertThat(cs.endpointSnapshot()).isNotNull(); + assertThat(endpointPort(cs)).isEqualTo(7777); + }); + + // Push an updated endpoint and verify the watcher picks up the change. + dogma.client().forRepo("test", "xds") + .commit("Update endpoint port", + Change.ofJsonUpsert("/endpoints/my-endpoint.json", + ENDPOINT_JSON.replace("7777", "6666"))) + .push() + .join(); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(endpointPort(snapshotRef.get())).isEqualTo(6666); + }); + } + } + + private static int endpointPort(ClusterSnapshot cs) { + return cs.endpointSnapshot().xdsResource().resource() + .getEndpoints(0).getLbEndpoints(0) + .getEndpoint().getAddress().getSocketAddress() + .getPortValue(); + } + + static Stream listenerTemplateArgs() { + return Stream.of( + Arguments.of("json", LISTENER_JSON_TEMPLATE, EDS_CLUSTER_JSON_TEMPLATE, + "/xds/two-routes.yaml", 2), + Arguments.of("json", LISTENER_JSON_TEMPLATE, EDS_CLUSTER_JSON_TEMPLATE, + "/xds/single-route.yaml", 1), + Arguments.of("yaml", LISTENER_YAML_TEMPLATE, EDS_CLUSTER_YAML_TEMPLATE, + "/xds/two-routes-yaml.yaml", 2), + Arguments.of("yaml", LISTENER_YAML_TEMPLATE, EDS_CLUSTER_YAML_TEMPLATE, + "/xds/single-route-yaml.yaml", 1) + ); + } + + @ParameterizedTest + @MethodSource("listenerTemplateArgs") + void templateListener(String ext, String listenerTemplate, String clusterTemplate, + String profilePath, int expectedRouteCount) { + final String profileContent = routesYaml(ext, expectedRouteCount); + dogma.client().forRepo("test", "xds") + .commit("Add " + ext + " listener template with profile " + profilePath, + Change.ofTextUpsert("/listeners/default-outbound." + ext + ".ftl", + listenerTemplate), + Change.ofTextUpsert("/clusters/default-outbound." + ext + ".ftl", clusterTemplate), + Change.ofJsonUpsert("/endpoints/my-endpoint.json", ENDPOINT_JSON), + Change.ofTextUpsert(EDS_CONFIG_PROFILE_PATH, EDS_CONFIG_PROFILE), + Change.ofTextUpsert(profilePath, profileContent)) + .push() + .join(); + + final String resourceName = + "test/xds/listeners/default-outbound." + ext + ".ftl?profile=" + profilePath; + + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ListenerSnapshot) { + snapshotRef.set((ListenerSnapshot) snapshot); + } + }) + .build(); + ListenerRoot listenerRoot = xdsBootstrap.listenerRoot(resourceName)) { + + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + final ListenerSnapshot ls = snapshotRef.get(); + assertThat(ls).isNotNull(); + assertThat(ls.xdsResource().resource().getName()).isEqualTo(resourceName); + + assertThat(ls.routeSnapshot()).isNotNull(); + assertThat(ls.routeSnapshot().xdsResource().resource() + .getVirtualHosts(0).getRoutesCount()) + .isEqualTo(expectedRouteCount); + + final ClusterSnapshot clusterSnapshot = + ls.routeSnapshot().virtualHostSnapshots().get(0) + .routeEntries().get(0).clusterSnapshot(); + assertThat(clusterSnapshot).isNotNull(); + assertThat(clusterSnapshot.xdsResource().resource().getName()) + .isEqualTo(clusterResourceName(ext)); + assertThat(clusterSnapshot.endpointSnapshot()).isNotNull(); + }); + } + } + + @Test + void camelCaseTemplateWithSnakeCaseProfile() { + // Template uses camelCase for protobuf fields, + // but the profile values use snake_case via toJson. + final String template = """ + { + "name": "%s", + "type": "STATIC", + "loadAssignment": ${toJson(vars.load_assignment)} + } + """.formatted(RESOURCE_NAME); + final String profile = """ + load_assignment: + cluster_name: test/xds/clusters/mixed-case.json.ftl?profile=/xds/mixed-case.yaml + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 4444 + """; + + dogma.client().forRepo("test", "xds") + .commit("Add mixed-case cluster", + Change.ofTextUpsert("/clusters/mixed-case.json.ftl", template), + Change.ofTextUpsert("/xds/mixed-case.yaml", profile)) + .push() + .join(); + + final String clusterName = "test/xds/clusters/mixed-case.json.ftl?profile=/xds/mixed-case.yaml"; + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot) { + snapshotRef.set((ClusterSnapshot) snapshot); + } + }) + .build()) { + xdsBootstrap.clusterRoot(clusterName); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + final ClusterSnapshot cs = snapshotRef.get(); + assertThat(cs).isNotNull(); + final Cluster cluster = cs.xdsResource().resource(); + assertThat(cluster.getName()).isEqualTo(clusterName); + assertThat(cluster.getLoadAssignment() + .getEndpoints(0).getLbEndpoints(0) + .getEndpoint().getAddress().getSocketAddress() + .getPortValue()) + .isEqualTo(4444); + }); + } + } +} diff --git a/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTest.java b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTest.java new file mode 100644 index 000000000..7cac75534 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/centraldogma/xds/it/CentralDogmaConfigSourceTest.java @@ -0,0 +1,464 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.xds.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import com.linecorp.armeria.xds.ClusterSnapshot; +import com.linecorp.armeria.xds.ListenerRoot; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.SnapshotWatcher; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.XdsResourceReader; +import com.linecorp.centraldogma.client.CentralDogma; +import com.linecorp.centraldogma.common.Change; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +class CentralDogmaConfigSourceTest { + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + @Override + protected void scaffold(CentralDogma client) { + client.createProject("test").join(); + client.createRepository("test", "xds").join(); + client.forRepo("test", "xds") + .commit("Add cluster", CLUSTER_CHANGE) + .push() + .join(); + } + }; + + private static final String HCM_TYPE = + "type.googleapis.com/envoy.extensions.filters.network" + + ".http_connection_manager.v3.HttpConnectionManager"; + private static final String ROUTER_TYPE = + "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router"; + + //language=JSON + private static final String CLUSTER_JSON = """ + { + "name": "test/xds/clusters/my-cluster.json", + "type": "STATIC", + "load_assignment": { + "cluster_name": "test/xds/clusters/my-cluster.json", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": 9999 + } + } + } + } + ] + } + ] + } + } + """; + + private static final Change CLUSTER_CHANGE = + Change.ofJsonUpsert("/clusters/my-cluster.json", CLUSTER_JSON); + private static final Change UPDATED_CLUSTER_CHANGE = + Change.ofJsonUpsert("/clusters/my-cluster.json", + CLUSTER_JSON.replace("9999", "8888")); + + private static String clusterJson(String name, int port) { + //language=JSON + return """ + { + "name": "%s", + "type": "STATIC", + "load_assignment": { + "cluster_name": "%s", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": %d + } + } + } + } + ] + } + ] + } + } + """.formatted(name, name, port); + } + + private static String listenerJson(String ext) { + //language=JSON + return """ + { + "name": "test/xds/listeners/my-listener.%s", + "api_listener": { + "api_listener": { + "@type": "%s", + "stat_prefix": "http", + "rds": { + "route_config_name": "test/xds/routes/my-route.%s", + "config_source": { "self": {} } + }, + "http_filters": [{ + "name": "envoy.filters.http.router", + "typed_config": { "@type": "%s" } + }] + } + } + } + """.formatted(ext, HCM_TYPE, ext, ROUTER_TYPE); + } + + private static String routeJson(String ext) { + //language=JSON + return """ + { + "name": "test/xds/routes/my-route.%s", + "virtual_hosts": [ + { + "name": "local_service", + "domains": ["*"], + "routes": [ + { + "match": { "prefix": "/" }, + "route": { "cluster": "test/xds/clusters/my-cluster.%s" } + } + ] + } + ] + } + """.formatted(ext, ext); + } + + private static String edsClusterJson(String ext) { + //language=JSON + return """ + { + "name": "test/xds/clusters/my-cluster.%s", + "type": "EDS", + "eds_cluster_config": { + "service_name": "test/xds/endpoints/my-cluster.%s", + "eds_config": { + "self": {} + } + } + } + """.formatted(ext, ext); + } + + private static String endpointJson(String ext) { + //language=JSON + return """ + { + "cluster_name": "test/xds/endpoints/my-cluster.%s", + "endpoints": [ + { + "lb_endpoints": [ + { + "endpoint": { + "address": { + "socket_address": { + "address": "127.0.0.1", + "port_value": 7777 + } + } + } + } + ] + } + ] + } + """.formatted(ext); + } + + private static String bootstrapYaml() { + final int port = dogma.serverAddress().getPort(); + //language=YAML + return """ + static_resources: + clusters: + - name: centraldogma-server + type: STATIC + load_assignment: + cluster_name: centraldogma-server + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %d + dynamic_resources: + cds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + lds_config: + custom_config_source: + name: centraldogma.config_source + typed_config: + "@type": type.googleapis.com/com.linecorp.centraldogma\ + .xds.v1.CentralDogmaConfigSource + cluster_name: centraldogma-server + """.formatted(port); + } + + @Test + void fetchClusterFromCentralDogma() { + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + final SnapshotWatcher watcher = (snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot) { + snapshotRef.set((ClusterSnapshot) snapshot); + } + }; + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher(watcher) + .build()) { + xdsBootstrap.clusterRoot("test/xds/clusters/my-cluster.json"); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(snapshotRef.get()).isNotNull(); + assertThat(snapshotRef.get().xdsResource().resource().getName()) + .isEqualTo("test/xds/clusters/my-cluster.json"); + assertThat(portValue(snapshotRef.get())).isEqualTo(9999); + }); + + // Push an update and verify the watcher emits the change. + dogma.client().forRepo("test", "xds") + .commit("Update cluster", UPDATED_CLUSTER_CHANGE) + .push() + .join(); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(portValue(snapshotRef.get())).isEqualTo(8888); + }); + } + } + + @Test + void clusterUpdateShouldBeReceivedAfterListenerSubscription() { + // Push a separate cluster so this test doesn't interfere with others. + final String clusterName = "test/xds/clusters/stable-cluster.json"; + dogma.client().forRepo("test", "xds") + .commit("Add stable cluster", + Change.ofJsonUpsert("/clusters/stable-cluster.json", + clusterJson(clusterName, 5555)), + Change.ofJsonUpsert("/listeners/standalone.json", + listenerJson("json"))) + .push() + .join(); + + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference clusterRef = new AtomicReference<>(); + final AtomicReference listenerRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + final SnapshotWatcher watcher = (snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot cs) { + clusterRef.set(cs); + } else if (snapshot instanceof ListenerSnapshot ls) { + listenerRef.set(ls); + } + }; + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher(watcher) + .build()) { + // 1. Subscribe to cluster and wait for it to resolve. + xdsBootstrap.clusterRoot(clusterName); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(clusterRef.get()).isNotNull(); + assertThat(portValue(clusterRef.get())).isEqualTo(5555); + }); + + // 2. Subscribe to a listener — this publishes InterestedResources(LDS, ...) + // on the same config source (identical ConfigSource protobuf). + // If switchMapEager destroys the CDS watchers, the cluster update below + // will never be received. + xdsBootstrap.listenerRoot("test/xds/listeners/standalone.json"); + + // 3. Update the cluster and verify the watcher still receives the change. + dogma.client().forRepo("test", "xds") + .commit("Update stable cluster", + Change.ofJsonUpsert("/clusters/stable-cluster.json", + clusterJson(clusterName, 6666))) + .push() + .join(); + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + assertThat(portValue(clusterRef.get())).isEqualTo(6666); + }); + } + } + + static Stream fullResourceChainArgs() { + return Stream.of( + Arguments.of("json", (ChangeFactory) Change::ofJsonUpsert), + Arguments.of("yaml", (ChangeFactory) Change::ofYamlUpsert) + ); + } + + @ParameterizedTest + @MethodSource("fullResourceChainArgs") + void fullResourceChain(String ext, ChangeFactory factory) { + dogma.client().forRepo("test", "xds") + .commit("Add full chain resources (" + ext + ')', + factory.create("/listeners/my-listener." + ext, listenerJson(ext)), + factory.create("/routes/my-route." + ext, routeJson(ext)), + factory.create("/clusters/my-cluster." + ext, edsClusterJson(ext)), + factory.create("/endpoints/my-cluster." + ext, endpointJson(ext))) + .push() + .join(); + + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher((snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ListenerSnapshot) { + snapshotRef.set((ListenerSnapshot) snapshot); + } + }) + .build(); + ListenerRoot listenerRoot = + xdsBootstrap.listenerRoot("test/xds/listeners/my-listener." + ext)) { + + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + final ListenerSnapshot listenerSnapshot = snapshotRef.get(); + assertThat(listenerSnapshot).isNotNull(); + assertThat(listenerSnapshot.xdsResource().resource().getName()) + .isEqualTo("test/xds/listeners/my-listener." + ext); + + assertThat(listenerSnapshot.routeSnapshot()).isNotNull(); + assertThat(listenerSnapshot.routeSnapshot().xdsResource().resource().getName()) + .isEqualTo("test/xds/routes/my-route." + ext); + + final ClusterSnapshot clusterSnapshot = + listenerSnapshot.routeSnapshot().virtualHostSnapshots().get(0) + .routeEntries().get(0).clusterSnapshot(); + assertThat(clusterSnapshot).isNotNull(); + assertThat(clusterSnapshot.xdsResource().resource().getName()) + .isEqualTo("test/xds/clusters/my-cluster." + ext); + + assertThat(clusterSnapshot.endpointSnapshot()).isNotNull(); + }); + } + } + + @Test + void multipleClusters() { + final int numClusters = 10; + final int basePort = 7000; + final Change[] changes = new Change[numClusters]; + for (int i = 0; i < numClusters; i++) { + final String name = "test/xds/clusters/cluster-" + i + ".json"; + changes[i] = Change.ofJsonUpsert("/clusters/cluster-" + i + ".json", + clusterJson(name, basePort + i)); + } + dogma.client().forRepo("test", "xds") + .commit("Add " + numClusters + " clusters", changes) + .push() + .join(); + + final Bootstrap bootstrap = XdsResourceReader.from(bootstrapYaml(), Bootstrap.class); + final Map snapshots = new ConcurrentHashMap<>(); + final AtomicReference errorRef = new AtomicReference<>(); + final SnapshotWatcher watcher = (snapshot, t) -> { + if (t != null) { + errorRef.set(t); + return; + } + if (snapshot instanceof ClusterSnapshot cs) { + snapshots.put(cs.xdsResource().resource().getName(), cs); + } + }; + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher(watcher) + .build()) { + for (int i = 0; i < numClusters; i++) { + xdsBootstrap.clusterRoot("test/xds/clusters/cluster-" + i + ".json"); + } + + await().untilAsserted(() -> { + assertThat(errorRef.get()).isNull(); + for (int i = 0; i < numClusters; i++) { + final String name = "test/xds/clusters/cluster-" + i + ".json"; + assertThat(snapshots).containsKey(name); + assertThat(portValue(snapshots.get(name))).isEqualTo(basePort + i); + } + }); + } + } + + private static int portValue(ClusterSnapshot snapshot) { + return snapshot.xdsResource().resource() + .getLoadAssignment() + .getEndpoints(0).getLbEndpoints(0) + .getEndpoint().getAddress().getSocketAddress() + .getPortValue(); + } + + @FunctionalInterface + private interface ChangeFactory { + Change create(String path, String content); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java index 4ce12c7e5..2ca90ac89 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/Templater.java @@ -83,6 +83,7 @@ public Templater(CommandExecutor executor, ProjectManager pm) { cfg.setBooleanFormat("c"); cfg.setAPIBuiltinEnabled(false); cfg.setNewBuiltinClassResolver(TemplateClassResolver.ALLOWS_NOTHING_RESOLVER); + cfg.setSharedVariable("toJson", new ToJsonMethod()); cache = Caffeine.newBuilder() .expireAfterAccess(Duration.ofHours(1)) @@ -115,12 +116,14 @@ public CompletableFuture> render(Repository repo, Entry entry, } final String projectName = project.name(); + final String repoName = repo.name(); // TODO(ikhoon): Optimize by caching the rendering result for the same set of variables and template. return mergeVariables(crudRepo.findAll(crudContext(projectName, normTemplateRevision)), - crudRepo.findAll(crudContext(projectName, repo.name(), normTemplateRevision)), + crudRepo.findAll(crudContext(projectName, repoName, normTemplateRevision)), findRepoVariableFile(repo, entry), findEntryPathVariableFile(repo, entry), - findClientVariableFile(repo, entry, variableFile)) + findClientVariableFile(repo, entry, variableFile), + projectName, repoName, entry.path(), variableFile) .thenApply(variables -> process(entry, variables, normTemplateRevision)) .toCompletableFuture(); } @@ -242,7 +245,9 @@ private static CompletionStage> mergeVariables( CompletableFuture>> repoFuture, CompletableFuture> repoFileFuture, CompletableFuture> entryPathFuture, - CompletableFuture> clientFileFuture) { + CompletableFuture> clientFileFuture, + String projectName, String repoName, String entryPath, + @Nullable String variableFile) { return CompletableFutures.combine( projFuture, repoFuture, repoFileFuture, entryPathFuture, clientFileFuture, (projVars, repoVars, repoFileVars, entryPathVars, clientFileVars) -> { @@ -266,12 +271,22 @@ private static CompletionStage> mergeVariables( builder.putAll(clientFileVars); final Map variables = builder.buildKeepingLast(); + final Map result = new HashMap<>(); // Prefix variables map with "vars" key. // This allows using "vars.varName" in the template. // TODO(ikhoon): Support secret variables that will be prefixed with "secrets" key. - final Map vars = new HashMap<>(); - vars.put("vars", variables); - return vars; + result.put("vars", variables); + // Always inject centraldogma context (project, repo, path, variableFile). + final ImmutableMap.Builder cdContext = + ImmutableMap.builder() + .put("project", projectName) + .put("repo", repoName) + .put("path", entryPath); + if (variableFile != null) { + cdContext.put("variableFile", variableFile); + } + result.put("centraldogma", cdContext.build()); + return result; }); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/ToJsonMethod.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/ToJsonMethod.java new file mode 100644 index 000000000..3cc1bfd9b --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/variable/ToJsonMethod.java @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LY Corporation + * + * LY 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.internal.api.variable; + +import java.util.List; + +import com.linecorp.centraldogma.internal.Jackson; + +import freemarker.template.TemplateMethodModelEx; +import freemarker.template.TemplateModel; +import freemarker.template.TemplateModelException; +import freemarker.template.utility.DeepUnwrap; + +/** + * A FreeMarker method that serializes a template variable (map, list, scalar) to a compact JSON string. + * Usage in templates: {@code ${toJson(vars.someObject)}} + */ +final class ToJsonMethod implements TemplateMethodModelEx { + + @Override + public Object exec(List arguments) throws TemplateModelException { + if (arguments.size() != 1) { + throw new TemplateModelException("toJson requires exactly 1 argument"); + } + final Object unwrapped = DeepUnwrap.unwrap((TemplateModel) arguments.get(0)); + try { + return Jackson.writeValueAsString(unwrapped); + } catch (Exception e) { + throw new TemplateModelException("Failed to serialize to JSON", e); + } + } +} diff --git a/settings.gradle b/settings.gradle index 686291fa0..8ec7a6cc4 100644 --- a/settings.gradle +++ b/settings.gradle @@ -31,6 +31,7 @@ includeWithFlags ':testing:testing-common', 'java', 'publish', ' includeWithFlags ':testing:junit', 'java', 'publish', 'relocate' includeWithFlags ':testing:junit4', 'java', 'publish', 'relocate' includeWithFlags ':xds', 'java', 'publish', 'relocate' +includeWithFlags ':xds-api', 'java', 'publish', 'relocate' // Set correct directory names project(':testing:testing-common').projectDir = file('testing/common') @@ -46,6 +47,7 @@ project(':it:it-server').projectDir = file('it/server') includeWithFlags ':it:mirror-listener', 'java', 'relocate' includeWithFlags ':it:server-healthy-plugin', 'java', 'relocate' includeWithFlags ':it:zone-leader-plugin', 'java', 'relocate' +includeWithFlags ':it:xds-client', 'java17', 'relocate' includeWithFlags ':it:xds-member-permission', 'java', 'relocate' includeWithFlags ':it:xds-k8s-node-ip-extractor', 'java', 'relocate' includeWithFlags ':testing-internal', 'java', 'relocate' diff --git a/xds-api/build.gradle b/xds-api/build.gradle new file mode 100644 index 000000000..4a4c3c079 --- /dev/null +++ b/xds-api/build.gradle @@ -0,0 +1,4 @@ +dependencies { + api libs.protobuf.java + api libs.armeria.xds.api +} diff --git a/xds-api/src/main/proto/centraldogma_config_source.proto b/xds-api/src/main/proto/centraldogma_config_source.proto new file mode 100644 index 000000000..84cb8372b --- /dev/null +++ b/xds-api/src/main/proto/centraldogma_config_source.proto @@ -0,0 +1,49 @@ +// Copyright 2026 LY Corporation +// +// LY 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. +syntax = "proto3"; + +package com.linecorp.centraldogma.xds.v1; + +import "envoy/extensions/transport_sockets/tls/v3/secret.proto"; +import "armeria/xds/supported.proto"; + +option java_multiple_files = true; +option java_outer_classname = "CentralDogmaConfigSourceProto"; +option java_package = "com.linecorp.centraldogma.xds.v1"; + +// Configuration for a Central Dogma-backed xDS config source. +// +// Resource names are encoded as ``{project}/{repo}/{path}``, where +// the first two path segments identify the Central Dogma project and +// repository, and the remainder is the file path within the repository. +message CentralDogmaConfigSource { + // The name of the cluster that provides connectivity to the Central Dogma server. + // This cluster must be defined in the bootstrap configuration. + option (armeria.xds.supported.field) = 1; + string cluster_name = 1; + + // The authentication method to use when connecting to the Central Dogma server. + // If not set, the client will connect as an anonymous user. + oneof auth { + option (armeria.xds.supported.oneof_field) = 2; + BearerTokenCredential bearer_token_credential = 2; + } +} + +// Authentication using a bearer token retrieved from a ``GenericSecret``. +message BearerTokenCredential { + // The token value (without the ``Bearer`` prefix). + option (armeria.xds.supported.field) = 1; + envoy.extensions.transport_sockets.tls.v3.SdsSecretConfig token_secret = 1; +}