diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpec.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpec.java index 44b9fa61741..62139a2eab6 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpec.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpec.java @@ -23,6 +23,10 @@ import javax.net.ssl.KeyManagerFactory; +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableSet; + import com.linecorp.armeria.common.AbstractTlsSpec; import com.linecorp.armeria.common.TlsKeyPair; import com.linecorp.armeria.common.TlsPeerVerifierFactory; @@ -54,14 +58,78 @@ public static ClientTlsSpecBuilder builder() { return new ClientTlsSpecBuilder(); } + private final Set overrideAlpnProtocols; + private final String endpointIdentificationAlgorithm; + ClientTlsSpec(Set tlsVersions, Set alpnProtocols, Set ciphers, @Nullable TlsKeyPair tlsKeyPair, List trustedCertificates, List verifierFactories, TlsEngineType engineType, Consumer tlsCustomizer, - @Nullable KeyManagerFactory keyManagerFactory) { - super(tlsVersions, alpnProtocols, ciphers, tlsKeyPair, trustedCertificates, verifierFactories, - engineType, tlsCustomizer, keyManagerFactory); + @Nullable KeyManagerFactory keyManagerFactory, + Set overrideAlpnProtocols, String endpointIdentificationAlgorithm) { + super(tlsVersions, alpnProtocols, ciphers, tlsKeyPair, + trustedCertificates, verifierFactories, engineType, tlsCustomizer, keyManagerFactory); + this.overrideAlpnProtocols = ImmutableSet.copyOf(overrideAlpnProtocols); + this.endpointIdentificationAlgorithm = endpointIdentificationAlgorithm; + } + + @Override + public Set alpnProtocols() { + if (!overrideAlpnProtocols.isEmpty()) { + return overrideAlpnProtocols; + } + return super.alpnProtocols(); + } + + Set baseAlpnProtocols() { + return super.alpnProtocols(); + } + + Set overrideAlpnProtocols() { + return overrideAlpnProtocols; + } + + /** + * Returns the endpoint identification algorithm used for JSSE hostname verification. + * An empty string {@code ""} disables JSSE hostname verification. + */ + public String endpointIdentificationAlgorithm() { + return endpointIdentificationAlgorithm; + } + + @Override + public boolean equals(@Nullable Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + if (!super.equals(o)) { + return false; + } + final ClientTlsSpec that = (ClientTlsSpec) o; + return Objects.equal(overrideAlpnProtocols, that.overrideAlpnProtocols) && + Objects.equal(endpointIdentificationAlgorithm, that.endpointIdentificationAlgorithm); + } + + @Override + public int hashCode() { + return Objects.hashCode(super.hashCode(), overrideAlpnProtocols, endpointIdentificationAlgorithm); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("tlsVersions", tlsVersions()) + .add("alpnProtocols", alpnProtocols()) + .add("ciphers", ciphers()) + .add("tlsKeyPair", tlsKeyPair()) + .add("trustedCertificates", trustedCertificates()) + .add("verifierFactories", verifierFactories()) + .add("engineType", engineType()) + .add("tlsCustomizer", tlsCustomizer()) + .add("keyManagerFactory", keyManagerFactory()) + .add("endpointIdentificationAlgorithm", endpointIdentificationAlgorithm()) + .toString(); } /** diff --git a/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpecBuilder.java b/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpecBuilder.java index 6ba73616620..82ea4c8be18 100644 --- a/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpecBuilder.java +++ b/core/src/main/java/com/linecorp/armeria/client/ClientTlsSpecBuilder.java @@ -18,11 +18,14 @@ import static java.util.Objects.requireNonNull; +import java.util.Collection; import java.util.Set; import java.util.function.Consumer; import javax.net.ssl.KeyManagerFactory; +import com.google.common.collect.ImmutableSet; + import com.linecorp.armeria.common.AbstractTlsSpecBuilder; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.annotation.Nullable; @@ -37,30 +40,45 @@ @UnstableApi public final class ClientTlsSpecBuilder extends AbstractTlsSpecBuilder { - private Set alpnProtocols = SslContextUtil.DEFAULT_ALPN_PROTOCOLS; + private Set baseAlpnProtocols = SslContextUtil.DEFAULT_ALPN_PROTOCOLS; + private Set overrideAlpnProtocols = ImmutableSet.of(); private Consumer tlsCustomizer = SslContextUtil.DEFAULT_NOOP_CUSTOMIZER; @Nullable private KeyManagerFactory keyManagerFactory; + private String endpointIdentificationAlgorithm = "HTTPS"; ClientTlsSpecBuilder() {} ClientTlsSpecBuilder(ClientTlsSpec clientTlsSpec) { super(clientTlsSpec.ciphers(), clientTlsSpec.tlsKeyPair(), clientTlsSpec.trustedCertificates(), clientTlsSpec.verifierFactories(), clientTlsSpec.engineType()); - alpnProtocols = clientTlsSpec.alpnProtocols(); + baseAlpnProtocols = clientTlsSpec.baseAlpnProtocols(); + overrideAlpnProtocols = clientTlsSpec.overrideAlpnProtocols(); keyManagerFactory = clientTlsSpec.keyManagerFactory(); tlsCustomizer = clientTlsSpec.tlsCustomizer(); + endpointIdentificationAlgorithm = clientTlsSpec.endpointIdentificationAlgorithm(); } ClientTlsSpecBuilder alpnProtocols(SessionProtocol sessionProtocol) { if (sessionProtocol.isExplicitHttp1()) { - alpnProtocols = SslContextUtil.DEFAULT_HTTP1_ALPN_PROTOCOLS; + baseAlpnProtocols = SslContextUtil.DEFAULT_HTTP1_ALPN_PROTOCOLS; } else { - alpnProtocols = SslContextUtil.DEFAULT_ALPN_PROTOCOLS; + baseAlpnProtocols = SslContextUtil.DEFAULT_ALPN_PROTOCOLS; } return this; } + /** + * Sets the ALPN protocol names that take precedence over the protocols + * derived from the {@link SessionProtocol}. If not empty, these are returned + * by {@link ClientTlsSpec#alpnProtocols()} instead of the default. + */ + public ClientTlsSpecBuilder alpnProtocols(Collection alpnProtocols) { + requireNonNull(alpnProtocols, "alpnProtocols"); + overrideAlpnProtocols = ImmutableSet.copyOf(alpnProtocols); + return this; + } + ClientTlsSpecBuilder tlsCustomizer(Consumer tlsCustomizer) { this.tlsCustomizer = requireNonNull(tlsCustomizer, "tlsCustomizer"); return this; @@ -71,13 +89,27 @@ ClientTlsSpecBuilder keyManagerFactory(KeyManagerFactory keyManagerFactory) { return this; } + /** + * Sets the endpoint identification algorithm for JSSE hostname verification. + * Use {@code "HTTPS"} (the default) for standard hostname verification, or {@code ""} + * to disable JSSE hostname verification (e.g. when peer identity is verified by a + * custom {@link com.linecorp.armeria.common.TlsPeerVerifierFactory}). + */ + public ClientTlsSpecBuilder endpointIdentificationAlgorithm(String algorithm) { + requireNonNull(algorithm, "algorithm"); + endpointIdentificationAlgorithm = algorithm; + return this; + } + /** * Returns a newly created {@link ClientTlsSpec} with the properties set so far. */ @Override public ClientTlsSpec build() { return new ClientTlsSpec(SslContextUtil.supportedTlsVersions(engineType().sslProvider()), - alpnProtocols, ciphers(), tlsKeyPair(), trustedCertificates(), - verifierFactories(), engineType(), tlsCustomizer, keyManagerFactory); + baseAlpnProtocols, ciphers(), tlsKeyPair(), + trustedCertificates(), verifierFactories(), engineType(), tlsCustomizer, + keyManagerFactory, overrideAlpnProtocols, + endpointIdentificationAlgorithm); } } diff --git a/core/src/main/java/com/linecorp/armeria/common/AbstractTlsSpec.java b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsSpec.java index d509dbcd3f0..29bdbfdd141 100644 --- a/core/src/main/java/com/linecorp/armeria/common/AbstractTlsSpec.java +++ b/core/src/main/java/com/linecorp/armeria/common/AbstractTlsSpec.java @@ -87,7 +87,7 @@ public final Set tlsVersions() { /** * Returns the supported ALPN protocols. */ - public final Set alpnProtocols() { + public Set alpnProtocols() { return alpnProtocols; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java index 7aec2408068..ee50125002e 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java +++ b/core/src/main/java/com/linecorp/armeria/internal/common/util/SslContextUtil.java @@ -72,15 +72,6 @@ public final class SslContextUtil { ImmutableSet.of(ApplicationProtocolNames.HTTP_1_1); public static final Consumer DEFAULT_NOOP_CUSTOMIZER = ignored -> {}; - private static final ApplicationProtocolConfig ALPN_CONFIG = new ApplicationProtocolConfig( - Protocol.ALPN, - // NO_ADVERTISE is currently the only mode supported by both OpenSsl and JDK providers. - SelectorFailureBehavior.NO_ADVERTISE, - // ACCEPT is currently the only mode supported by both OpenSsl and JDK providers. - SelectedListenerFailureBehavior.ACCEPT, - ApplicationProtocolNames.HTTP_2, - ApplicationProtocolNames.HTTP_1_1); - // OpenSSL's default enabled TLSv1.3 ciphers as documented at https://wiki.openssl.org/index.php/TLS1.3 private static final List TLS_V13_CIPHERS = ImmutableList.of("TLS_AES_256_GCM_SHA384", "TLS_CHACHA20_POLY1305_SHA256", @@ -139,7 +130,7 @@ private static SslContext toSslContext0(ClientTlsSpec clientTlsSpec) throws Exce if (keyPair != null) { builder.keyManager(keyPair.privateKey(), keyPair.certificateChain()); } - builder.endpointIdentificationAlgorithm("HTTPS"); + builder.endpointIdentificationAlgorithm(clientTlsSpec.endpointIdentificationAlgorithm()); applyCommonConfigs(clientTlsSpec, builder); diff --git a/dependencies.toml b/dependencies.toml index 86299e22392..4e76f51b7da 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -52,6 +52,7 @@ hibernate-validator8 = "8.0.3.Final" # used by :it:xds-istio istio = "1.29.1" j2objc = "3.1" +json-path = "3.0.0" jackson = "2.21.2" jakarta-inject = "2.0.1" jakarta-validation = "3.1.1" @@ -591,6 +592,10 @@ version.ref = "hibernate-validator8" module = "com.google.j2objc:j2objc-annotations" version.ref = "j2objc" +[libraries.json-path] +module = "com.jayway.jsonpath:json-path" +version.ref = "json-path" + [libraries.jakarta-inject] module = "jakarta.inject:jakarta.inject-api" version.ref = "jakarta-inject" diff --git a/it/xds-client/build.gradle b/it/xds-client/build.gradle index ac0df725141..5e2761fef65 100644 --- a/it/xds-client/build.gradle +++ b/it/xds-client/build.gradle @@ -4,5 +4,4 @@ dependencies { api libs.protobuf.java.util api libs.controlplane.server api libs.controlplane.cache - api libs.protoc.pgv.java.stub } diff --git a/it/xds-client/src/main/java/com/linecorp/armeria/xds/it/XdsResourceReader.java b/it/xds-client/src/main/java/com/linecorp/armeria/xds/it/XdsResourceReader.java index 6c5ed8337e8..43c3312addd 100644 --- a/it/xds-client/src/main/java/com/linecorp/armeria/xds/it/XdsResourceReader.java +++ b/it/xds-client/src/main/java/com/linecorp/armeria/xds/it/XdsResourceReader.java @@ -21,7 +21,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.google.common.escape.Escaper; import com.google.common.escape.Escapers; -import com.google.protobuf.GeneratedMessage; +import com.google.protobuf.Message; import com.google.protobuf.util.JsonFormat; import com.google.protobuf.util.JsonFormat.Parser; import com.google.protobuf.util.JsonFormat.TypeRegistry; @@ -54,10 +54,10 @@ public static Bootstrap fromYaml(String yaml) { } @SuppressWarnings("unchecked") - public static T fromYaml(String yaml, Class clazz) { - final GeneratedMessage.Builder builder; + public static T fromYaml(String yaml, Class clazz) { + final Message.Builder builder; try { - builder = (GeneratedMessage.Builder) clazz.getMethod("newBuilder").invoke(null); + builder = (Message.Builder) clazz.getMethod("newBuilder").invoke(null); final JsonNode jsonNode = mapper.reader().readTree(yaml); parser.merge(jsonNode.toString(), builder); } catch (Exception e) { @@ -67,10 +67,10 @@ public static T fromYaml(String yaml, Class claz } @SuppressWarnings("unchecked") - public static T fromJson(String json, Class clazz) { - final GeneratedMessage.Builder builder; + public static T fromJson(String json, Class clazz) { + final Message.Builder builder; try { - builder = (GeneratedMessage.Builder) clazz.getMethod("newBuilder").invoke(null); + builder = (Message.Builder) clazz.getMethod("newBuilder").invoke(null); final JsonNode jsonNode = jsonMapper.reader().readTree(json); parser.merge(jsonNode.toString(), builder); } catch (Exception e) { diff --git a/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java new file mode 100644 index 00000000000..9cf08846f61 --- /dev/null +++ b/it/xds-client/src/test/java/com/linecorp/armeria/xds/it/PipeEndpointTest.java @@ -0,0 +1,355 @@ +/* + * 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.armeria.xds.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.atomic.AtomicLong; +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 org.junit.jupiter.api.io.TempDir; + +import com.google.common.collect.ImmutableList; + +import com.linecorp.armeria.client.ClientFactory; +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.util.DomainSocketAddress; +import com.linecorp.armeria.internal.testing.EnabledOnOsWithDomainSockets; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.grpc.GrpcService; +import com.linecorp.armeria.testing.junit5.server.SelfSignedCertificateExtension; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.SnapshotWatcher; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +import io.envoyproxy.controlplane.cache.v3.SimpleCache; +import io.envoyproxy.controlplane.cache.v3.Snapshot; +import io.envoyproxy.controlplane.server.V3DiscoveryServer; +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.Secret; +import io.netty.handler.ssl.ClientAuth; + +@EnabledOnOsWithDomainSockets +class PipeEndpointTest { + + private static final String SOCKET_PATH = + System.getProperty("java.io.tmpdir") + "/armeria-xds-pipe-" + + Long.toHexString(ThreadLocalRandom.current().nextLong()) + ".sock"; + + private static final String GROUP = "key"; + private static final SimpleCache cache = new SimpleCache<>(node -> GROUP); + private static final AtomicLong version = new AtomicLong(); + + @TempDir + static Path tempDir; + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension serverCert = + new SelfSignedCertificateExtension("localhost"); + + @RegisterExtension + @Order(0) + static final SelfSignedCertificateExtension clientCert = + new SelfSignedCertificateExtension(); + + @RegisterExtension + @Order(1) + static final ServerExtension sdsServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.http(DomainSocketAddress.of(tempDir.resolve("sds.sock").toString())); + final V3DiscoveryServer v3DiscoveryServer = new V3DiscoveryServer(cache); + sb.service(GrpcService.builder() + .addService(v3DiscoveryServer.getAggregatedDiscoveryServiceImpl()) + .addService(v3DiscoveryServer.getSecretDiscoveryServiceImpl()) + .build()); + } + }; + + @RegisterExtension + @Order(1) + static final ServerExtension backendServer = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.tls(serverCert.certificateFile(), serverCert.privateKeyFile()); + sb.tlsCustomizer(ssl -> { + ssl.clientAuth(ClientAuth.REQUIRE); + ssl.trustManager(clientCert.certificate()); + }); + sb.service("/hello", (ctx, req) -> HttpResponse.of("world")); + } + }; + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) { + sb.http(DomainSocketAddress.of(SOCKET_PATH)); + sb.service("/hello", (ctx, req) -> HttpResponse.of("world")); + } + }; + + //language=YAML + private static final String bootstrapTemplate = + """ + static_resources: + listeners: + - name: my-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\ + .v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: my-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: my-cluster + type: STATIC + load_assignment: + cluster_name: my-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: %s + """; + + //language=YAML + private static final String strictDnsBootstrapTemplate = + """ + static_resources: + clusters: + - name: my-cluster + type: STRICT_DNS + load_assignment: + cluster_name: my-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: %s + """; + + //language=YAML + private static final String sdsBootstrapTemplate = + """ + dynamic_resources: + ads_config: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: sds-cluster + static_resources: + clusters: + - name: sds-cluster + type: STATIC + load_assignment: + cluster_name: sds-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: %s + - name: my-cluster + type: STATIC + load_assignment: + cluster_name: my-cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: %s + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets\ + .tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: client-cert + sds_config: + ads: {} + validation_context_sds_secret_config: + name: server-ca + sds_config: + ads: {} + listeners: + - name: my-listener + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager\ + .v3.HttpConnectionManager + stat_prefix: http + route_config: + name: local_route + virtual_hosts: + - name: local_service + domains: ["*"] + routes: + - match: + prefix: / + route: + cluster: my-cluster + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """; + + @Test + void pipeEndpointRouted() throws Exception { + final String bootstrapYaml = bootstrapTemplate.formatted(SOCKET_PATH); + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class); + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener("my-listener", xdsBootstrap)) { + final String response = WebClient.builder(preprocessor) + .factory(ClientFactory.insecure()) + .build() + .blocking() + .get("/hello") + .contentUtf8(); + assertThat(response).isEqualTo("world"); + } + } + + @Test + void pipeEndpointInStrictDnsThrows() throws Exception { + final String bootstrapYaml = strictDnsBootstrapTemplate.formatted(SOCKET_PATH); + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class); + final AtomicReference errorRef = new AtomicReference<>(); + // The error fires during static-cluster initialization (before clusterRoot() is even called), + // so the defaultSnapshotWatcher — installed before the pipeline starts — is the only + // reliable observer. + final SnapshotWatcher watcher = (snapshot, t) -> { + if (t != null) { + errorRef.set(t); + } + }; + try (XdsBootstrap xdsBootstrap = XdsBootstrap.builder(bootstrap) + .defaultSnapshotWatcher(watcher) + .build()) { + xdsBootstrap.clusterRoot("my-cluster"); + await().untilAsserted(() -> + assertThat(errorRef.get()) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContaining("Pipe addresses are not supported for STRICT_DNS")); + } + } + + @Test + void sdsViaControlPlanePipe() throws Exception { + // Push both secrets atomically in a single snapshot: + // client-cert → the TLS key pair the xDS client presents to the backend (mTLS) + // server-ca → the CA the xDS client uses to trust the backend's server cert + final Secret clientCertSecret = tlsCertSecret("client-cert", clientCert); + final Secret serverCaSecret = validationContextSecret("server-ca", serverCert); + version.incrementAndGet(); + cache.setSnapshot(GROUP, Snapshot.create( + ImmutableList.of(), ImmutableList.of(), ImmutableList.of(), ImmutableList.of(), + ImmutableList.of(clientCertSecret, serverCaSecret), + version.toString())); + + final String bootstrapYaml = + sdsBootstrapTemplate.formatted(tempDir.resolve("sds.sock").toString(), + backendServer.httpsPort()); + final Bootstrap bootstrap = XdsResourceReader.fromYaml(bootstrapYaml, Bootstrap.class); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener("my-listener", xdsBootstrap)) { + + // Wait until the listener snapshot is fully assembled (SDS secrets resolved) + final AtomicReference snapshotRef = new AtomicReference<>(); + xdsBootstrap.listenerRoot("my-listener") + .addSnapshotWatcher((snapshot, t) -> { + if (snapshot != null) { + snapshotRef.set(snapshot); + } + }); + await().untilAsserted(() -> assertThat(snapshotRef.get()).isNotNull()); + try (ClientFactory factory = ClientFactory.builder().connectTimeoutMillis(3000).build()) { + // mTLS request must succeed end-to-end + final AggregatedHttpResponse response = + WebClient.builder(preprocessor) + .factory(factory) + .build() + .blocking() + .get("/hello"); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("world"); + } + } + } + + private static Secret tlsCertSecret(String name, SelfSignedCertificateExtension cert) { + final String yaml = """ + name: %s + tls_certificate: + private_key: + filename: %s + certificate_chain: + filename: %s + """.formatted(name, + cert.privateKeyFile().toPath().toString(), + cert.certificateFile().toPath().toString()); + return XdsResourceReader.fromYaml(yaml, Secret.class); + } + + private static Secret validationContextSecret(String name, + SelfSignedCertificateExtension cert) + throws Exception { + final byte[] caBytes = Files.readAllBytes(cert.certificateFile().toPath()); + final String yaml = """ + name: %s + validation_context: + trusted_ca: + inline_bytes: %s + """.formatted(name, Base64.getEncoder().encodeToString(caBytes)); + return XdsResourceReader.fromYaml(yaml, Secret.class); + } +} diff --git a/it/xds-istio/build.gradle b/it/xds-istio/build.gradle index 185ee3f8ea0..93242e04721 100644 --- a/it/xds-istio/build.gradle +++ b/it/xds-istio/build.gradle @@ -4,6 +4,11 @@ dependencies { implementation libs.junit5.platform.launcher implementation libs.testcontainers.k3s implementation libs.testcontainers.junit.jupiter + + testImplementation project(':xds') + testImplementation(libs.json.path) { + exclude group: 'org.slf4j' + } } def kubeconfigEnvValue = @@ -81,7 +86,10 @@ tasks.register('prepareIstioWorkdir') { tasks.register('copyTestRuntimeJars', Sync) { dependsOn tasks.named('prepareIstioWorkdir') - from(sourceSets.test.runtimeClasspath) + dependsOn tasks.named('copyShadedTestClasses') + from(configurations.shadedJarTestRuntime) + from(sourceSets.main.output) + from(sourceSets.test.output) into(testRuntimeDir) } diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/GrpcProxylessPodCustomizer.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/GrpcProxylessPodCustomizer.java new file mode 100644 index 00000000000..e12ff57956d --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/GrpcProxylessPodCustomizer.java @@ -0,0 +1,58 @@ +/* + * 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.armeria.it.istio.testing; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; + +/** + * A {@link PodCustomizer} for Istio gRPC proxyless mode. Uses the + * {@code inject.istio.io/templates: grpc-agent} annotation which injects a lightweight + * {@code pilot-agent} sidecar (no Envoy) that generates bootstrap files and manages + * certificates for non-Envoy gRPC/xDS clients. + * + *

The {@code grpc-agent} template causes Istiod to use the {@code grpc} generator, + * producing xDS configs with FQDN-based listener names + * (e.g. {@code echo-server.default.svc.cluster.local:8080}) instead of IP-based names. + */ +public final class GrpcProxylessPodCustomizer implements PodCustomizer { + + @Override + public void customizePod(PodBuilder podBuilder) { + podBuilder.editMetadata() + .addToAnnotations("inject.istio.io/templates", "grpc-agent") + .endMetadata(); + } + + @Override + public boolean isPodHealthy(Pod pod) { + if (pod.getStatus() == null || !"Running".equals(pod.getStatus().getPhase())) { + return false; + } + final var statuses = pod.getStatus().getContainerStatuses(); + if (statuses == null) { + return false; + } + if (statuses.stream().noneMatch(cs -> "istio-proxy".equals(cs.getName()))) { + throw new IllegalStateException( + "Pod '" + pod.getMetadata().getName() + "' is running but has no " + + "istio-proxy container — grpc-agent injection did not occur"); + } + return statuses.stream() + .filter(cs -> "istio-proxy".equals(cs.getName())) + .anyMatch(cs -> Boolean.TRUE.equals(cs.getReady())); + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java index a222443de7c..39aea5a1158 100644 --- a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java @@ -31,6 +31,15 @@ public final class IstioPodCustomizer implements PodCustomizer { public void customizePod(PodBuilder podBuilder) { podBuilder.editMetadata() .addToAnnotations("sidecar.istio.io/inject", "true") + // Disable Delta xDS, forcing SotW instead. Istio treats CDS/LDS as wildcard + // types, which causes a hot loop with Delta xDS: + // 1) Armeria subscribes to cluster "A" + // 2) Istiod ignores the subscription and sends all clusters "A", "B", "C" + // 3) Armeria unsubscribes from "B" and "C" to match its interest set + // 4) Istiod responds with all clusters again → back to step 3 + // SotW doesn't have per-resource subscriptions, so this doesn't occur. + .addToAnnotations("proxy.istio.io/config", + "{\"proxyMetadata\":{\"ISTIO_DELTA_XDS\":\"false\"}}") .endMetadata() .editSpec() .editMatchingContainer(c -> "test".equals(c.getName())) diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/K8sClusterHelper.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/K8sClusterHelper.java index 0540ab84467..c3c0b28d178 100644 --- a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/K8sClusterHelper.java +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/K8sClusterHelper.java @@ -27,6 +27,12 @@ import org.testcontainers.k3s.K3sContainer; import org.testcontainers.utility.DockerImageName; +import com.linecorp.armeria.client.WebClientBuilder; +import com.linecorp.armeria.client.kubernetes.ArmeriaHttpClientFactory; +import com.linecorp.armeria.client.retry.RetryConfig; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.client.retry.RetryingClient; + import io.fabric8.kubernetes.api.model.Node; import io.fabric8.kubernetes.api.model.NodeCondition; import io.fabric8.kubernetes.client.Config; @@ -79,10 +85,17 @@ static KubernetesClient createClient(Path kubeconfigPath) throws IOException { private static KubernetesClient createClient(String kubeconfig) { final Config config = Config.fromKubeconfig(kubeconfig); - config.setConnectionTimeout(3_000); - config.setRequestTimeout(3_000); - config.setRequestRetryBackoffLimit(0); - return new KubernetesClientBuilder().withConfig(config).build(); + return new KubernetesClientBuilder() + .withHttpClientFactory(new ArmeriaHttpClientFactory() { + @Override + protected void additionalConfig(WebClientBuilder builder) { + builder.maxResponseLength(Long.MAX_VALUE); + builder.decorator(RetryingClient.newDecorator(RetryConfig.builder(RetryRule.failsafe()) + .maxTotalAttempts(3) + .build())); + } + }) + .withConfig(config).build(); } static boolean poll(Duration timeout, Duration interval, BooleanSupplier condition) { diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EnvoyDebugTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EnvoyDebugTest.java index 6f9ffe839ff..2ea5241eba1 100644 --- a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EnvoyDebugTest.java +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EnvoyDebugTest.java @@ -30,6 +30,8 @@ import org.slf4j.LoggerFactory; import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.retry.RetryRule; +import com.linecorp.armeria.client.retry.RetryingClient; import com.linecorp.armeria.common.AggregatedHttpResponse; import com.linecorp.armeria.common.HttpStatus; import com.linecorp.armeria.it.istio.testing.EnabledIfDockerAvailable; @@ -58,9 +60,15 @@ class EnvoyDebugTest { static IstioServerExtension echo = new IstioServerExtension( "echo-server", PORT, EchoConfigurator.class); + private static WebClient webClient(String uri) { + return WebClient.builder(uri) + .decorator(RetryingClient.newDecorator(RetryRule.failsafe())) + .build(); + } + @IstioPodTest void serverIsReachable() { - final WebClient client = WebClient.of("http://" + echo.serviceName() + ':' + echo.port()); + final WebClient client = webClient("http://" + echo.serviceName() + ':' + echo.port()); final AggregatedHttpResponse response = client.get("/echo").aggregate().join(); assertThat(response.status()).isEqualTo(HttpStatus.OK); assertThat(response.contentUtf8()).isEqualTo("hello"); @@ -68,7 +76,7 @@ void serverIsReachable() { @IstioPodTest void envoyStatsAreReachable() { - final WebClient envoyAdmin = WebClient.of("http://localhost:15000"); + final WebClient envoyAdmin = webClient("http://localhost:15000"); final AggregatedHttpResponse response = envoyAdmin.get("/stats").aggregate().join(); assertThat(response.status()).isEqualTo(HttpStatus.OK); assertThat(response.contentUtf8()).contains("server.state"); @@ -76,8 +84,8 @@ void envoyStatsAreReachable() { @IstioPodTest void envoyConfigDump() { - final WebClient envoyAdmin = WebClient.of("http://localhost:15000"); - final AggregatedHttpResponse response = envoyAdmin.get("/config_dump").aggregate().join(); + final WebClient envoyAdmin = webClient("http://localhost:15000"); + final AggregatedHttpResponse response = envoyAdmin.get("/config_dump?include_eds").aggregate().join(); assertThat(response.status()).isEqualTo(HttpStatus.OK); logger.info("Envoy config dump: {}", response.contentUtf8()); } diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/NoopXdsValidatorIndex.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/NoopXdsValidatorIndex.java new file mode 100644 index 00000000000..cce8a08ea4b --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/NoopXdsValidatorIndex.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 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.armeria.it.xds; + +import com.linecorp.armeria.xds.validator.XdsValidatorIndex; + +/** + * A no-op {@link XdsValidatorIndex} that skips all protobuf validation. + * + *

Istio's gRPC proxyless mode ({@code GENERATOR: grpc}) produces xDS resources + * with empty {@code stat_prefix} in {@code HttpConnectionManager}, which fails + * the default pgv validation. This validator allows such resources to be processed. + */ +public class NoopXdsValidatorIndex implements XdsValidatorIndex { + @Override + public void assertValid(Object message) { + } + + @Override + public int priority() { + return 1; + } +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToSidecarTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToSidecarTest.java new file mode 100644 index 00000000000..2620bdd3b4f --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsClientToSidecarTest.java @@ -0,0 +1,317 @@ +/* + * Copyright 2025 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.armeria.it.xds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.logging.LoggingClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.it.istio.testing.EnabledIfDockerAvailable; +import com.linecorp.armeria.it.istio.testing.IstioClusterExtension; +import com.linecorp.armeria.it.istio.testing.IstioPodTest; +import com.linecorp.armeria.it.istio.testing.IstioServerExtension; +import com.linecorp.armeria.xds.ClusterRoot; +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.client.endpoint.XdsHttpPreprocessor; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.config.listener.v3.Listener; + +@EnabledIfDockerAvailable +class XdsClientToSidecarTest { + + private static final Logger logger = LoggerFactory.getLogger(XdsClientToSidecarTest.class); + + @RegisterExtension + @Order(1) + static IstioClusterExtension cluster = new IstioClusterExtension(); + + @RegisterExtension + @Order(2) + static IstioServerExtension server = + new IstioServerExtension("echo-server", 8080, EchoConfigurator.class); + + @IstioPodTest + void clusterLoad() throws Exception { + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + final String clusterName = "outbound|8080||echo-server.default.svc.cluster.local"; + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + ClusterRoot clusterRoot = xdsBootstrap.clusterRoot(clusterName)) { + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + clusterRoot.addSnapshotWatcher((snapshot, t) -> { + logger.info("Cluster snapshot: {}, t: ", snapshot, t); + if (snapshot != null) { + snapshotRef.compareAndSet(null, snapshot); + } + if (t != null) { + errorRef.compareAndSet(null, t); + } + }); + + await().untilAsserted(() -> { + final Throwable t = errorRef.get(); + if (t != null) { + throw new AssertionError("Failed to load cluster snapshot", t); + } + assertThat(snapshotRef.get()).isNotNull(); + }); + } + } + + @IstioPodTest + void clusterRequest() throws Exception { + final String clusterName = "outbound|8080||echo-server.default.svc.cluster.local"; + final String listenerName = "armeria-test-cluster-listener"; + final Listener listener = listenerWithStaticRoute(listenerName, clusterName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + final Bootstrap.StaticResources staticResources = + parsedBootstrap.getStaticResources().toBuilder() + .addListeners(listener) + .build(); + final Bootstrap bootstrap = parsedBootstrap.toBuilder() + .setStaticResources(staticResources) + .build(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) { + final WebClient client = WebClient.builder(preprocessor) + .decorator(LoggingClient.newDecorator()) + .build(); + final AggregatedHttpResponse response = client.get("/echo").aggregate().join(); + logger.info("Response: {}", response); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("hello"); + } + } + + @IstioPodTest + void routeLoad() throws Exception { + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + final String listenerName = "armeria-test-ads-listener"; + final String routeConfigName = "echo-server.default.svc.cluster.local:8080"; + final Listener listener = listenerWithRdsAds(listenerName, routeConfigName); + + final Bootstrap.StaticResources staticResources = parsedBootstrap.getStaticResources().toBuilder() + .addListeners(listener) + .build(); + + final Bootstrap.DynamicResources dynamicResources = parsedBootstrap.getDynamicResources(); + final Bootstrap bootstrap = parsedBootstrap.toBuilder() + .setStaticResources(staticResources) + .setDynamicResources(dynamicResources) + .build(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + ListenerRoot listenerRoot = xdsBootstrap.listenerRoot(listenerName)) { + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + listenerRoot.addSnapshotWatcher((snapshot, t) -> { + logger.info("Listener snapshot: {}, t: ", snapshot, t); + if (snapshot != null) { + snapshotRef.compareAndSet(null, snapshot); + } + if (t != null) { + errorRef.compareAndSet(null, t); + } + }); + + await().untilAsserted(() -> { + final Throwable t = errorRef.get(); + if (t != null) { + throw new AssertionError("Failed to load listener snapshot via ADS", t); + } + final ListenerSnapshot snapshot = snapshotRef.get(); + assertThat(snapshot).isNotNull(); + assertThat(snapshot.routeSnapshot()).isNotNull(); + assertThat(snapshot.routeSnapshot().xdsResource().resource().getName()) + .isEqualTo(routeConfigName); + }); + logger.info("Loaded listener snapshot via ADS: {}", snapshotRef.get()); + } + } + + @IstioPodTest + void routeRequest() throws Exception { + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + final String listenerName = "armeria-test-ads-listener"; + final String routeConfigName = "echo-server.default.svc.cluster.local:8080"; + final Listener listener = listenerWithRdsAds(listenerName, routeConfigName); + + final Bootstrap.StaticResources staticResources = + parsedBootstrap.getStaticResources().toBuilder() + .addListeners(listener) + .build(); + final Bootstrap bootstrap = parsedBootstrap.toBuilder() + .setStaticResources(staticResources) + .build(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(bootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) { + final WebClient client = WebClient.builder(preprocessor) + .decorator(LoggingClient.newDecorator()) + .build(); + final AggregatedHttpResponse response = client.get("/echo").aggregate().join(); + logger.info("Response: {}", response); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("hello"); + } + } + + @IstioPodTest + void listenerLoad() throws Exception { + // Resolve the Kubernetes Service ClusterIP dynamically via in-cluster DNS. + // Istio names outbound listeners "{clusterIP}_{port}". + final String serviceIp = InetAddress.getByName( + server.serviceName() + ".default.svc.cluster.local").getHostAddress(); + final String listenerName = serviceIp + '_' + server.port(); + logger.info("Istio outbound listener name resolved: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + ListenerRoot listenerRoot = xdsBootstrap.listenerRoot(listenerName)) { + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + listenerRoot.addSnapshotWatcher((snapshot, t) -> { + logger.info("Outbound listener snapshot: {}, error: ", snapshot, t); + if (snapshot != null) { + snapshotRef.compareAndSet(null, snapshot); + } + if (t != null) { + errorRef.compareAndSet(null, t); + } + }); + + await().untilAsserted(() -> { + final Throwable t = errorRef.get(); + if (t != null) { + throw new AssertionError("Failed to load outbound listener snapshot", t); + } + final ListenerSnapshot snapshot = snapshotRef.get(); + assertThat(snapshot).isNotNull(); + assertThat(snapshot.routeSnapshot()).isNotNull(); + assertThat(snapshot.routeSnapshot().xdsResource().resource().getName()) + .contains(server.serviceName()); + }); + logger.info("Outbound listener snapshot loaded: {}", snapshotRef.get()); + Thread.sleep(10_000); + } + } + + @IstioPodTest + void listenerRequest() throws Exception { + final String serviceIp = InetAddress.getByName( + server.serviceName() + ".default.svc.cluster.local").getHostAddress(); + final String listenerName = serviceIp + '_' + server.port(); + logger.info("Istio outbound listener name resolved: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + XdsHttpPreprocessor preprocessor = XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) { + final WebClient client = WebClient.builder(preprocessor) + .decorator(LoggingClient.newDecorator()) + .build(); + final AggregatedHttpResponse response = client.get("/echo").aggregate().join(); + logger.info("Response: {}", response); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("hello"); + } + } + + private static Listener listenerWithStaticRoute(String name, String clusterName) { + //language=YAML + final String yaml = + """ + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: %s + route_config: + name: local_route + virtual_hosts: + - name: echo_service + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: %s + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(name, name, clusterName); + return XdsResourceReader.fromYaml(yaml, Listener.class); + } + + private static Listener listenerWithRdsAds(String name, String routeConfigName) { + //language=YAML + final String yaml = + """ + name: %s + api_listener: + api_listener: + "@type": type.googleapis.com/envoy.extensions.filters.network\ + .http_connection_manager.v3.HttpConnectionManager + stat_prefix: %s + rds: + route_config_name: %s + config_source: + ads: {} + http_filters: + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + """.formatted(name, name, routeConfigName); + return XdsResourceReader.fromYaml(yaml, Listener.class); + } + + private static Bootstrap loadParsedBootstrap() throws Exception { + final Path bootstrapPath = Paths.get("/etc/istio/proxy/envoy-rev.json"); + assertThat(bootstrapPath).exists(); + logger.info("Using Istio bootstrap file: {}", bootstrapPath); + final String bootstrapJson = Files.readString(bootstrapPath); + final String rewritten = XdsResourceReader.rewriteXdsGrpcBootstrap(bootstrapJson); + return XdsResourceReader.fromJson(rewritten, Bootstrap.class); + } +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsGrpcProxylessTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsGrpcProxylessTest.java new file mode 100644 index 00000000000..b78edd43214 --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsGrpcProxylessTest.java @@ -0,0 +1,144 @@ +/* + * Copyright 2025 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.armeria.it.xds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jayway.jsonpath.JsonPath; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.logging.LoggingClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.it.istio.testing.EnabledIfDockerAvailable; +import com.linecorp.armeria.it.istio.testing.IstioClusterExtension; +import com.linecorp.armeria.it.istio.testing.IstioPodTest; +import com.linecorp.armeria.it.istio.testing.IstioServerExtension; +import com.linecorp.armeria.xds.ListenerRoot; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +/** + * Tests xDS with Istio's gRPC proxyless mode ({@code GENERATOR: grpc}). + * This is not using full gRPC proxyless mode, but just the gRPC generator for xDS configs. + * + *

In this mode, Istiod generates xDS configs designed for non-Envoy gRPC clients: + * listener names use FQDNs (e.g. {@code echo-server.default.svc.cluster.local:8080}) + * instead of IP-based names (e.g. {@code 10.43.139.201_8080}). + */ +@EnabledIfDockerAvailable +class XdsGrpcProxylessTest { + + private static final Logger logger = LoggerFactory.getLogger(XdsGrpcProxylessTest.class); + + @RegisterExtension + @Order(1) + static IstioClusterExtension cluster = new IstioClusterExtension(); + + @RegisterExtension + @Order(2) + static IstioServerExtension server = + new IstioServerExtension("echo-server", 8080, EchoConfigurator.class); + + @IstioPodTest + void listenerLoad() throws Exception { + final String listenerName = + server.serviceName() + ".default.svc.cluster.local:" + server.port(); + logger.info("gRPC proxyless listener name: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + ListenerRoot listenerRoot = xdsBootstrap.listenerRoot(listenerName)) { + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + listenerRoot.addSnapshotWatcher((snapshot, t) -> { + logger.info("gRPC proxyless listener snapshot: {}, error: ", snapshot, t); + if (snapshot != null) { + snapshotRef.compareAndSet(null, snapshot); + } + if (t != null) { + errorRef.compareAndSet(null, t); + } + }); + + await().untilAsserted(() -> { + final Throwable t = errorRef.get(); + if (t != null) { + throw new AssertionError("Failed to load gRPC proxyless listener snapshot", t); + } + final ListenerSnapshot snapshot = snapshotRef.get(); + assertThat(snapshot).isNotNull(); + assertThat(snapshot.routeSnapshot()).isNotNull(); + }); + logger.info("gRPC proxyless listener snapshot loaded: {}", snapshotRef.get()); + logger.info("gRPC proxyless listener snapshot loaded: {}", snapshotRef.get().toDebugString()); + } + } + + @IstioPodTest + void listenerRequest() throws Exception { + final String listenerName = + server.serviceName() + ".default.svc.cluster.local:" + server.port(); + logger.info("gRPC proxyless listener name: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) { + final WebClient client = WebClient.builder(preprocessor) + .decorator(LoggingClient.newDecorator()) + .build(); + final AggregatedHttpResponse response = client.execute( + RequestHeaders.builder(HttpMethod.GET, "/echo") + .authority("echo-server") + .build()).aggregate().join(); + logger.info("Response: {}", response); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("hello"); + } + } + + private static Bootstrap loadParsedBootstrap() throws Exception { + final Path bootstrapPath = Paths.get("/etc/istio/proxy/envoy-rev.json"); + assertThat(bootstrapPath).exists(); + logger.info("Using Istio bootstrap file: {}", bootstrapPath); + final String bootstrapJson = Files.readString(bootstrapPath); + final String rewritten = XdsResourceReader.rewriteXdsGrpcBootstrap(bootstrapJson); + // Set GENERATOR=grpc in node metadata to enable gRPC proxyless mode. + final String withGenerator = JsonPath.parse(rewritten) + .put("$.node.metadata", "GENERATOR", "grpc") + .jsonString(); + return XdsResourceReader.fromJson(withGenerator, Bootstrap.class); + } +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsNativeGrpcProxylessTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsNativeGrpcProxylessTest.java new file mode 100644 index 00000000000..235460c22be --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsNativeGrpcProxylessTest.java @@ -0,0 +1,209 @@ +/* + * 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.armeria.it.xds; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.client.logging.LoggingClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.armeria.it.istio.testing.EnabledIfDockerAvailable; +import com.linecorp.armeria.it.istio.testing.GrpcProxylessPodCustomizer; +import com.linecorp.armeria.it.istio.testing.IstioClusterExtension; +import com.linecorp.armeria.it.istio.testing.IstioPodTest; +import com.linecorp.armeria.it.istio.testing.IstioServerExtension; +import com.linecorp.armeria.xds.ListenerRoot; +import com.linecorp.armeria.xds.ListenerSnapshot; +import com.linecorp.armeria.xds.XdsBootstrap; +import com.linecorp.armeria.xds.client.endpoint.XdsHttpPreprocessor; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; + +/** + * Tests xDS with Istio's native gRPC proxyless mode. Unlike {@link XdsGrpcProxylessTest} which + * reads the Envoy bootstrap and mutates it, this test uses the {@code grpc-agent} template + * ({@link GrpcProxylessPodCustomizer}) which produces a gRPC-style bootstrap. The node identity + * is extracted from that bootstrap and used to construct an Envoy-style bootstrap that Armeria's + * {@link XdsBootstrap} can consume. + */ +@EnabledIfDockerAvailable +class XdsNativeGrpcProxylessTest { + + private static final Logger logger = LoggerFactory.getLogger(XdsNativeGrpcProxylessTest.class); + + @RegisterExtension + @Order(1) + static IstioClusterExtension cluster = new IstioClusterExtension(); + + @RegisterExtension + @Order(2) + static IstioServerExtension server = + new IstioServerExtension("echo-server", 8080, EchoConfigurator.class); + + @IstioPodTest(podCustomizer = GrpcProxylessPodCustomizer.class) + void bootstrapFile() throws Exception { + final Path dir = Paths.get("/etc/istio/proxy"); + final List filenames; + try (Stream stream = Files.list(dir)) { + filenames = stream.map(p -> p.getFileName().toString()) + .collect(Collectors.toList()); + } + logger.info("/etc/istio/proxy contents: {}", filenames); + assertThat(filenames).anyMatch(name -> name.endsWith(".json")); + + for (String name : filenames) { + if (name.endsWith(".json")) { + logger.info("gRPC proxyless bootstrap file ('{}'):\n{}", name, + Files.readString(dir.resolve(name))); + } + } + } + + @IstioPodTest(podCustomizer = GrpcProxylessPodCustomizer.class) + void listenerLoad() throws Exception { + final String listenerName = + server.serviceName() + ".default.svc.cluster.local:" + server.port(); + logger.info("Native gRPC proxyless listener name: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + ListenerRoot listenerRoot = xdsBootstrap.listenerRoot(listenerName)) { + final AtomicReference snapshotRef = new AtomicReference<>(); + final AtomicReference errorRef = new AtomicReference<>(); + listenerRoot.addSnapshotWatcher((snapshot, t) -> { + logger.info("Native gRPC proxyless listener snapshot: {}, error: ", snapshot, t); + if (snapshot != null) { + snapshotRef.compareAndSet(null, snapshot); + } + if (t != null) { + errorRef.compareAndSet(null, t); + } + }); + + await().untilAsserted(() -> { + final Throwable t = errorRef.get(); + if (t != null) { + throw new AssertionError( + "Failed to load native gRPC proxyless listener snapshot", t); + } + final ListenerSnapshot snapshot = snapshotRef.get(); + assertThat(snapshot).isNotNull(); + assertThat(snapshot.routeSnapshot()).isNotNull(); + }); + logger.info("Native gRPC proxyless listener snapshot loaded: {}", snapshotRef.get()); + logger.info("Native gRPC proxyless listener snapshot loaded: {}", + snapshotRef.get().toDebugString()); + } + } + + @IstioPodTest(podCustomizer = GrpcProxylessPodCustomizer.class) + void listenerRequest() throws Exception { + final String listenerName = + server.serviceName() + ".default.svc.cluster.local:" + server.port(); + logger.info("Native gRPC proxyless listener name: {}", listenerName); + + final Bootstrap parsedBootstrap = loadParsedBootstrap(); + + try (XdsBootstrap xdsBootstrap = XdsBootstrap.of(parsedBootstrap); + XdsHttpPreprocessor preprocessor = + XdsHttpPreprocessor.ofListener(listenerName, xdsBootstrap)) { + final WebClient client = WebClient.builder(preprocessor) + .decorator(LoggingClient.newDecorator()) + .build(); + final AggregatedHttpResponse response = client.execute( + RequestHeaders.builder(HttpMethod.GET, "/echo") + .authority("echo-server") + .build()).aggregate().join(); + logger.info("Response: {}", response); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("hello"); + } + } + + /** + * Reads the gRPC-style bootstrap ({@code grpc-bootstrap.json}) generated by the + * {@code grpc-agent} sidecar, extracts the node identity and server URI, and + * constructs an Envoy-style {@link Bootstrap} that Armeria's {@link XdsBootstrap} + * can consume. + */ + private static Bootstrap loadParsedBootstrap() throws Exception { + final Path bootstrapPath = Paths.get("/etc/istio/proxy/grpc-bootstrap.json"); + assertThat(bootstrapPath).exists(); + final String bootstrapJson = Files.readString(bootstrapPath); + logger.info("gRPC bootstrap:\n{}", bootstrapJson); + + final DocumentContext ctx = JsonPath.parse(bootstrapJson); + final String nodeJson = Configuration.defaultConfiguration() + .jsonProvider() + .toJson(ctx.read("$.node")); + // server_uri is e.g. "unix:///etc/istio/proxy/XDS" + final String serverUri = ctx.read("$.xds_servers[0].server_uri"); + final String pipePath = serverUri.replaceFirst("^unix://", ""); + + //language=YAML + final String yaml = + """ + node: %s + dynamic_resources: + ads_config: + api_type: GRPC + set_node_on_first_message_only: true + grpc_services: + - envoy_grpc: + cluster_name: xds-grpc + lds_config: + ads: {} + cds_config: + ads: {} + static_resources: + clusters: + - name: xds-grpc + type: STATIC + load_assignment: + cluster_name: xds-grpc + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: %s + """.formatted(nodeJson, pipePath); + + return XdsResourceReader.fromYaml(yaml, Bootstrap.class); + } +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java new file mode 100644 index 00000000000..e75a9fb27a4 --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/XdsResourceReader.java @@ -0,0 +1,143 @@ +/* + * Copyright 2024 LINE 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.armeria.it.xds; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.escape.Escaper; +import com.google.common.escape.Escapers; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import com.google.protobuf.util.JsonFormat.Parser; +import com.google.protobuf.util.JsonFormat.TypeRegistry; +import com.jayway.jsonpath.Configuration; +import com.jayway.jsonpath.JsonPath; + +import io.envoyproxy.envoy.config.bootstrap.v3.Bootstrap; +import io.envoyproxy.envoy.extensions.access_loggers.file.v3.FileAccessLog; +import io.envoyproxy.envoy.extensions.compression.brotli.compressor.v3.Brotli; +import io.envoyproxy.envoy.extensions.compression.gzip.compressor.v3.Gzip; +import io.envoyproxy.envoy.extensions.compression.zstd.compressor.v3.Zstd; +import io.envoyproxy.envoy.extensions.filters.http.compressor.v3.Compressor; +import io.envoyproxy.envoy.extensions.filters.http.router.v3.Router; +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager; +import io.envoyproxy.envoy.extensions.resource_monitors.downstream_connections.v3.DownstreamConnectionsConfig; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext; +import io.envoyproxy.envoy.extensions.upstreams.http.v3.HttpProtocolOptions; + +public final class XdsResourceReader { + + private static final ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + private static final ObjectMapper jsonMapper = new ObjectMapper(); + // ignoringUnknownFields() lets the parser skip proto fields that have no + // matching descriptor — callers are responsible for stripping Any-typed + // extension fields (e.g. typedExtensionProtocolOptions) before parsing so + // that no additional TypeRegistry entries are needed here. + private static final Parser parser = + JsonFormat.parser() + .ignoringUnknownFields() + .usingTypeRegistry(TypeRegistry.newBuilder() + .add(com.github.udpa.udpa.type.v1.TypedStruct + .getDescriptor()) + .add(com.github.xds.type.v3.TypedStruct.getDescriptor()) + .add(FileAccessLog.getDescriptor()) + .add(HttpProtocolOptions.getDescriptor()) + .add(Compressor.getDescriptor()) + .add(Brotli.getDescriptor()) + .add(Zstd.getDescriptor()) + .add(Gzip.getDescriptor()) + .add(DownstreamConnectionsConfig.getDescriptor()) + .add(HttpConnectionManager.getDescriptor()) + .add(Router.getDescriptor()) + .add(UpstreamTlsContext.getDescriptor()) + .build()); + + public static Bootstrap fromYaml(String yaml) { + final Bootstrap.Builder bootstrapBuilder = Bootstrap.newBuilder(); + try { + final JsonNode jsonNode = mapper.reader().readTree(yaml); + parser.merge(jsonNode.toString(), bootstrapBuilder); + } catch (Exception e) { + throw new RuntimeException(e); + } + return bootstrapBuilder.build(); + } + + @SuppressWarnings("unchecked") + public static T fromYaml(String yaml, Class clazz) { + final Message.Builder builder; + try { + builder = (Message.Builder) clazz.getMethod("newBuilder").invoke(null); + final JsonNode jsonNode = mapper.reader().readTree(yaml); + parser.merge(jsonNode.toString(), builder); + } catch (Exception e) { + throw new RuntimeException(e); + } + return (T) builder.build(); + } + + @SuppressWarnings("unchecked") + public static T fromJson(String json, Class clazz) { + final Message.Builder builder; + try { + builder = (Message.Builder) clazz.getMethod("newBuilder").invoke(null); + final JsonNode jsonNode = jsonMapper.reader().readTree(json); + parser.merge(jsonNode.toString(), builder); + } catch (Exception e) { + throw new RuntimeException(e); + } + return (T) builder.build(); + } + + private static final Escaper multiLineEscaper = Escapers.builder() + .addEscape('\\', "\\\\") + .addEscape('"', "\\\"") + .addEscape('\n', "\\n") + .addEscape('\r', "\\r") + .build(); + + /** + * Rewrites the {@code xds-grpc} cluster's load assignment to connect directly to + * Istiod's plaintext gRPC port (15010) instead of pilot-agent's UDS proxy. + * This is due to the restriction that pilot-agent only allows a single active connection. + */ + static String rewriteXdsGrpcBootstrap(String bootstrapJson) { + return JsonPath.parse(bootstrapJson) + .set("$.static_resources.clusters[?(@.name=='xds-grpc')].load_assignment", + Configuration.defaultConfiguration().jsonProvider().parse(""" + { + "cluster_name": "xds-grpc", + "endpoints": [{ + "lb_endpoints": [{ + "endpoint": { + "address": { + "socket_address": { + "address": "istiod.istio-system.svc", + "port_value": 15010 + } + } + } + }] + }] + } + """)) + .jsonString(); + } + + private XdsResourceReader() {} +} diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioNoOpFilterFactories.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioNoOpFilterFactories.java new file mode 100644 index 00000000000..61e1d57b238 --- /dev/null +++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/filter/IstioNoOpFilterFactories.java @@ -0,0 +1,100 @@ +/* + * Copyright 2025 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.armeria.it.xds.filter; + +import com.google.protobuf.Any; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.xds.filter.HttpFilterFactory; +import com.linecorp.armeria.xds.filter.XdsHttpFilter; + +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter; + +/** + * No-op {@link HttpFilterFactory} implementations for Istio/Envoy-specific HTTP filters + * that Armeria has no built-in factory for. Registered via SPI so they are picked up + * automatically by {@link com.linecorp.armeria.xds.filter.HttpFilterFactoryRegistry}. + * Returns {@code null} from {@code create()} so the filter is silently skipped. + */ +public final class IstioNoOpFilterFactories { + + public abstract static class Base implements HttpFilterFactory { + @Override + @Nullable + public XdsHttpFilter create(HttpFilter httpFilter, Any config) { + return null; + } + } + + public static final class IstioStats extends Base { + @Override + public String filterName() { + return "istio.stats"; + } + } + + public static final class IstioAlpn extends Base { + @Override + public String filterName() { + return "istio.alpn"; + } + } + + public static final class IstioMetadataExchange extends Base { + @Override + public String filterName() { + return "istio.metadata_exchange"; + } + } + + public static final class EnvoyFault extends Base { + @Override + public String filterName() { + return "envoy.filters.http.fault"; + } + } + + public static final class EnvoyCors extends Base { + @Override + public String filterName() { + return "envoy.filters.http.cors"; + } + } + + public static final class EnvoyGzipCompressor extends Base { + @Override + public String filterName() { + return "envoy.filters.http.compressor.gzip"; + } + } + + public static final class EnvoyZstdCompressor extends Base { + @Override + public String filterName() { + return "envoy.filters.http.compressor.zstd"; + } + } + + public static final class EnvoyGrpcStats extends Base { + @Override + public String filterName() { + return "envoy.filters.http.grpc_stats"; + } + } + + private IstioNoOpFilterFactories() {} +} diff --git a/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory b/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory new file mode 100644 index 00000000000..b638d9e2591 --- /dev/null +++ b/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.filter.HttpFilterFactory @@ -0,0 +1,8 @@ +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$IstioStats +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$IstioAlpn +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$IstioMetadataExchange +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$EnvoyFault +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$EnvoyCors +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$EnvoyGzipCompressor +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$EnvoyZstdCompressor +com.linecorp.armeria.it.xds.filter.IstioNoOpFilterFactories$EnvoyGrpcStats diff --git a/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.validator.XdsValidatorIndex b/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.validator.XdsValidatorIndex new file mode 100644 index 00000000000..f2910a2fd2a --- /dev/null +++ b/it/xds-istio/src/test/resources/META-INF/services/com.linecorp.armeria.xds.validator.XdsValidatorIndex @@ -0,0 +1 @@ +com.linecorp.armeria.it.xds.NoopXdsValidatorIndex diff --git a/settings.gradle b/settings.gradle index f74a0995656..2168e0e632e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -261,7 +261,7 @@ includeWithFlags ':it:thrift0.9.1', 'java', 'relocate includeWithFlags ':it:trace-context-leak', 'java', 'relocate' includeWithFlags ':it:websocket', 'java11', 'relocate' includeWithFlags ':it:xds-client', 'java17' -includeWithFlags ':it:xds-istio', 'java17' +includeWithFlags ':it:xds-istio', 'java17', 'relocate' includeWithFlags ':it:xds-no-validation', 'java17' includeWithFlags ':jetty9.3', 'java', 'relocate' project(':jetty9.3').projectDir = file('jetty/jetty9.3') diff --git a/xds-api/build.gradle b/xds-api/build.gradle index ef6f6117508..9735c93c6ec 100644 --- a/xds-api/build.gradle +++ b/xds-api/build.gradle @@ -15,7 +15,6 @@ dependencies { testImplementation libs.protobuf.java testImplementation libs.protobuf.java.util - testImplementation libs.protoc.pgv.java.stub testImplementation libs.re2j } diff --git a/xds/build.gradle b/xds/build.gradle index b373688390d..b2f4fff2167 100644 --- a/xds/build.gradle +++ b/xds/build.gradle @@ -14,7 +14,4 @@ dependencies { testImplementation libs.controlplane.server testImplementation libs.controlplane.cache - testImplementation (libs.protoc.pgv.java.stub) { - exclude group: 'com.google.protobuf' - } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java index 1993a8319b1..f8c8eb6ec75 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/CertificateValidationContextSnapshot.java @@ -37,6 +37,7 @@ import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.CertificateValidationContext; import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.SubjectAltNameMatcher; +import io.envoyproxy.envoy.type.matcher.v3.StringMatcher; /** * A snapshot of a {@link CertificateValidationContext} resource with its trusted CA certificates. @@ -67,6 +68,15 @@ public final class CertificateValidationContextSnapshot implements Snapshot { /** * Returns the {@link XdsResource} of the current snapshot. */ T xdsResource(); + + /** + * Returns a debug string including the full protobuf content of all resources. + */ + String toDebugString(); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/SnapshotUtil.java b/xds/src/main/java/com/linecorp/armeria/xds/SnapshotUtil.java new file mode 100644 index 00000000000..67146f01fc2 --- /dev/null +++ b/xds/src/main/java/com/linecorp/armeria/xds/SnapshotUtil.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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.armeria.xds; + +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.linecorp.armeria.common.annotation.Nullable; + +final class SnapshotUtil { + + @Nullable + static String debugString(@Nullable T obj, Function toDebugString) { + return obj != null ? toDebugString.apply(obj) : null; + } + + static List debugStrings(Collection items, Function toDebugString) { + return items.stream().map(toDebugString).collect(Collectors.toList()); + } + + private SnapshotUtil() {} +} diff --git a/xds/src/main/java/com/linecorp/armeria/xds/TlsCertificateSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/TlsCertificateSnapshot.java index 77714ad68bd..9f8758caf34 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/TlsCertificateSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/TlsCertificateSnapshot.java @@ -75,8 +75,13 @@ public int hashCode() { @Override public String toString() { return MoreObjects.toStringHelper(this) - .omitNullValues() - .add("resource", resource) + .toString(); + } + + @Override + public String toDebugString() { + return MoreObjects.toStringHelper(this) + .add("tlsCertificate", resource) .toString(); } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketMatchSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketMatchSnapshot.java index 4228af62c1b..d97cc0b239e 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketMatchSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketMatchSnapshot.java @@ -77,4 +77,12 @@ public String toString() { .add("transportSocketSnapshot", transportSocketSnapshot) .toString(); } + + @Override + public String toDebugString() { + return MoreObjects.toStringHelper(this) + .add("transportSocketMatch", transportSocketMatch) + .add("transportSocket", transportSocketSnapshot.toDebugString()) + .toString(); + } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java index 63c1b7c8cd5..5d49bce5e7e 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketSnapshot.java @@ -19,6 +19,7 @@ import java.security.cert.X509Certificate; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,6 +36,7 @@ import com.linecorp.armeria.common.annotation.UnstableApi; import io.envoyproxy.envoy.config.core.v3.TransportSocket; +import io.envoyproxy.envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext; /** * A snapshot of a {@link TransportSocket} resource with its associated TLS configuration. @@ -45,6 +47,7 @@ public final class TransportSocketSnapshot implements Snapshot { private static final Logger logger = LoggerFactory.getLogger(TransportSocketSnapshot.class); + private static final String istioPeerExchange = "istio-peer-exchange"; private static boolean warnedNoVerify; private final TransportSocket transportSocket; @@ -63,12 +66,13 @@ public final class TransportSocketSnapshot implements Snapshot } TransportSocketSnapshot(TransportSocket transportSocket, + @Nullable UpstreamTlsContext upstreamTlsContext, Optional tlsCertificate, Optional validationContext) { this.transportSocket = transportSocket; this.tlsCertificate = tlsCertificate.orElse(null); this.validationContext = validationContext.orElse(null); - clientTlsSpec = buildClientTlsSpec(this.tlsCertificate, this.validationContext); + clientTlsSpec = buildClientTlsSpec(upstreamTlsContext, this.tlsCertificate, this.validationContext); } @Override @@ -101,9 +105,19 @@ public TransportSocket xdsResource() { } private static ClientTlsSpec buildClientTlsSpec( + @Nullable UpstreamTlsContext upstreamTlsContext, @Nullable TlsCertificateSnapshot tlsCertificate, @Nullable CertificateValidationContextSnapshot validationContext) { - final ClientTlsSpecBuilder specBuilder = ClientTlsSpec.builder(); + final ClientTlsSpecBuilder specBuilder = ClientTlsSpec.builder() + .endpointIdentificationAlgorithm(""); + if (upstreamTlsContext != null) { + // Armeria doesn't implement "istio-peer-exchange" for now. + final List alpn = upstreamTlsContext.getCommonTlsContext().getAlpnProtocolsList(); + final List filteredAlpn = alpn.stream() + .filter(a -> !istioPeerExchange.equals(a)) + .collect(Collectors.toList()); + specBuilder.alpnProtocols(filteredAlpn); + } final ImmutableList.Builder verifiersBuilder = ImmutableList.builder(); if (validationContext != null) { final boolean systemRootCerts = validationContext.xdsResource().hasSystemRootCerts(); @@ -112,7 +126,8 @@ private static ClientTlsSpec buildClientTlsSpec( if (trustedCa != null) { specBuilder.trustedCertificates(trustedCa); } else if (systemRootCerts) { - // use java default root CAs + // use java default root CAs, also enable JSSE + specBuilder.endpointIdentificationAlgorithm("HTTPS"); } else { warnNoVerifyOnce(); verifiersBuilder.add(TlsPeerVerifierFactory.noVerify()); @@ -173,9 +188,22 @@ public int hashCode() { public String toString() { return MoreObjects.toStringHelper(this) .omitNullValues() - .add("transportSocket", transportSocket) .add("tlsCertificate", tlsCertificate) .add("validationContext", validationContext) .toString(); } + + @Override + public String toDebugString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("transportSocket", transportSocket) + .add("tlsCertificate", + SnapshotUtil.debugString(tlsCertificate, + TlsCertificateSnapshot::toDebugString)) + .add("validationContext", + SnapshotUtil.debugString(validationContext, + CertificateValidationContextSnapshot::toDebugString)) + .toString(); + } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketStream.java b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketStream.java index bc901956a1c..bb3304a67f7 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketStream.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/TransportSocketStream.java @@ -105,7 +105,7 @@ protected Subscription onStart(SnapshotWatcher watcher) final SnapshotStream stream = SnapshotStream.combineLatest(tlsCertStream, validationStream, (cert, validation) -> { - return new TransportSocketSnapshot(transportSocket, cert, validation); + return new TransportSocketSnapshot(transportSocket, tlsContext, cert, validation); }); return stream.subscribe(watcher); } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/VirtualHostSnapshot.java b/xds/src/main/java/com/linecorp/armeria/xds/VirtualHostSnapshot.java index f338bdfcf4d..c5dcad89c0c 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/VirtualHostSnapshot.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/VirtualHostSnapshot.java @@ -86,4 +86,13 @@ public String toString() { .add("routeEntries", routeEntries) .toString(); } + + @Override + public String toDebugString() { + return MoreObjects.toStringHelper(this) + .add("virtualHost", virtualHostXdsResource.resource()) + .add("routeEntries", + SnapshotUtil.debugStrings(routeEntries, RouteEntry::toDebugString)) + .toString(); + } } diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsEndpointUtil.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsEndpointUtil.java index d6c86d71bbd..15469b097d3 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsEndpointUtil.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsEndpointUtil.java @@ -20,6 +20,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import static com.linecorp.armeria.xds.client.endpoint.XdsConstants.SUBSET_LOAD_BALANCING_FILTER_NAME; +import java.nio.file.Paths; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -37,6 +38,7 @@ import com.linecorp.armeria.client.endpoint.dns.DnsAddressEndpointGroup; import com.linecorp.armeria.client.endpoint.dns.DnsAddressEndpointGroupBuilder; import com.linecorp.armeria.client.retry.Backoff; +import com.linecorp.armeria.common.util.DomainSocketAddress; import com.linecorp.armeria.xds.ClusterXdsResource; import com.linecorp.armeria.xds.EndpointSnapshot; import com.linecorp.armeria.xds.TransportSocketMatchSnapshot; @@ -139,7 +141,13 @@ private static EndpointGroup strictDnsEndpointGroup( final ClusterLoadAssignment loadAssignment = endpointSnapshot.xdsResource().resource(); for (LocalityLbEndpoints localityLbEndpoints: loadAssignment.getEndpointsList()) { for (LbEndpoint lbEndpoint: localityLbEndpoints.getLbEndpointsList()) { - final SocketAddress socketAddress = lbEndpoint.getEndpoint().getAddress().getSocketAddress(); + final io.envoyproxy.envoy.config.core.v3.Address address = + lbEndpoint.getEndpoint().getAddress(); + if (address.hasPipe()) { + throw new UnsupportedOperationException( + "Pipe addresses are not supported for STRICT_DNS cluster type"); + } + final SocketAddress socketAddress = address.getSocketAddress(); final String dnsAddress = socketAddress.getAddress(); final DnsAddressEndpointGroupBuilder builder = DnsAddressEndpointGroup.builder(dnsAddress); if (socketAddress.hasPortValue()) { @@ -191,12 +199,24 @@ private static Endpoint convertToEndpoint( LocalityLbEndpoints localityLbEndpoints, LbEndpoint lbEndpoint, TransportSocketSnapshot transportSocket, List transportSocketMatches) { - final SocketAddress socketAddress = - lbEndpoint.getEndpoint().getAddress().getSocketAddress(); - final String hostname = lbEndpoint.getEndpoint().getHostname(); + final io.envoyproxy.envoy.config.core.v3.Address address = + lbEndpoint.getEndpoint().getAddress(); final int weight = endpointWeight(lbEndpoint); final TransportSocketSnapshot matchedTransport = TransportSocketMatchUtil.selectTransportSocket( transportSocket, transportSocketMatches, lbEndpoint, localityLbEndpoints); + + if (address.hasPipe()) { + final String pipePath = Paths.get(address.getPipe().getPath()).toAbsolutePath().toString(); + return DomainSocketAddress.of(pipePath) + .asEndpoint() + .withAttr(XdsAttributeKeys.LB_ENDPOINT_KEY, lbEndpoint) + .withAttr(XdsAttributeKeys.LOCALITY_LB_ENDPOINTS_KEY, localityLbEndpoints) + .withAttr(XdsCommonUtil.TRANSPORT_SOCKET_SNAPSHOT_KEY, matchedTransport) + .withWeight(weight); + } + + final SocketAddress socketAddress = address.getSocketAddress(); + final String hostname = lbEndpoint.getEndpoint().getHostname(); final Endpoint endpoint; if (!Strings.isNullOrEmpty(hostname)) { endpoint = Endpoint.of(hostname) diff --git a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java index ef12afd7b00..065db871ef5 100644 --- a/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java +++ b/xds/src/main/java/com/linecorp/armeria/xds/client/endpoint/XdsHealthCheckedEndpointGroupBuilder.java @@ -30,6 +30,7 @@ import com.linecorp.armeria.common.HttpMethod; import com.linecorp.armeria.common.SessionProtocol; import com.linecorp.armeria.common.util.AsyncCloseable; +import com.linecorp.armeria.common.util.DomainSocketAddress; import com.linecorp.armeria.internal.client.endpoint.healthcheck.DefaultHttpHealthChecker; import io.envoyproxy.envoy.config.cluster.v3.Cluster; @@ -102,7 +103,11 @@ private static Endpoint endpoint(HealthCheckConfig healthCheckConfig, Endpoint e endpoint = endpoint.withPort(port); } if (healthCheckConfig.hasAddress()) { - return endpoint.withHost(healthCheckConfig.getAddress().getSocketAddress().getAddress()); + final io.envoyproxy.envoy.config.core.v3.Address addr = healthCheckConfig.getAddress(); + if (addr.hasPipe()) { + return DomainSocketAddress.of(addr.getPipe().getPath()).asEndpoint(); + } + return endpoint.withHost(addr.getSocketAddress().getAddress()); } return endpoint; }