diff --git a/docs/examples.md b/docs/examples.md index c63fa739817..49dd2c5ad34 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -17,3 +17,4 @@ Examples of different use cases provided by Testcontainers can be found below: - [Zookeeper](https://github.com/testcontainers/testcontainers-java/tree/main/examples/zookeeper) - [NATS](https://github.com/testcontainers/testcontainers-java/tree/main/examples/nats) - [SFTP](https://github.com/testcontainers/testcontainers-java/tree/main/examples/sftp) +- [Envoy](https://github.com/testcontainers/testcontainers-java/tree/main/examples/envoy) diff --git a/examples/envoy/build.gradle b/examples/envoy/build.gradle new file mode 100644 index 00000000000..3c258d1c13f --- /dev/null +++ b/examples/envoy/build.gradle @@ -0,0 +1,24 @@ +plugins { + id 'java' +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation 'org.assertj:assertj-core:3.27.4' + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:testcontainers-junit-jupiter' + testImplementation 'io.envoyproxy.controlplane:server:1.0.55' + // keep in sync with the gRPC version used by java-control-plane + testImplementation 'io.grpc:grpc-netty-shaded:1.62.2' + testImplementation 'org.awaitility:awaitility:4.3.0' + testImplementation 'ch.qos.logback:logback-classic:1.3.15' + testImplementation 'org.junit.jupiter:junit-jupiter:5.13.4' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.11.0' +} + +test { + useJUnitPlatform() +} diff --git a/examples/envoy/src/test/java/com/example/EnvoyContainerTest.java b/examples/envoy/src/test/java/com/example/EnvoyContainerTest.java new file mode 100644 index 00000000000..bfe3d3154ac --- /dev/null +++ b/examples/envoy/src/test/java/com/example/EnvoyContainerTest.java @@ -0,0 +1,196 @@ +package com.example; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.Testcontainers; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.Network; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.junit.jupiter.Container; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Shows how to set up a service mesh style environment with Testcontainers: every service gets an Envoy sidecar, + * service-to-service traffic goes through the sidecars, and their configuration is delivered over xDS by a control + * plane running in the test JVM (see {@link XdsControlPlane}). + *
+ * The assertions only demonstrate that the example is wired correctly. They are not a test of Envoy itself. + * + *
+ * test (client app) -> client-sidecar :15001 -> server-sidecar :15006 -> server-app :8080 + * ^ ^ + * +------ xDS (ADS) -------+ + * control plane in test JVM + *+ */ +@org.testcontainers.junit.jupiter.Testcontainers +class EnvoyContainerTest { + + private static final String ENVOY_IMAGE = "envoyproxy/envoy:v1.39.1"; + + private static final int OUTBOUND_PORT = 15001; + + private static final int INBOUND_PORT = 15006; + + private static final int ADMIN_PORT = 9901; + + // client-sidecar adds this value as the x-service-caller header to every outbound request + private static final String CLIENT_CALLER = "client"; + + private static final XdsControlPlane controlPlane = startControlPlane(); + + private static final Network network = Network.newNetwork(); + + @Container + private static final GenericContainer> serverApp = new GenericContainer<>("mendhak/http-https-echo:41") + .withNetwork(network) + .withNetworkAliases("server-app") + .withEnv("HTTP_PORT", "8080") + .withExposedPorts(8080) + .waitingFor(Wait.forHttp("/").forPort(8080)); + + @Container + private static final GenericContainer> serverSidecar = sidecar("server-sidecar", INBOUND_PORT) + .dependsOn(serverApp); + + @Container + private static final GenericContainer> clientSidecar = sidecar("client-sidecar", OUTBOUND_PORT) + .dependsOn(serverSidecar); + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private static XdsControlPlane startControlPlane() { + XdsControlPlane controlPlane = new XdsControlPlane(); + // lets the sidecar containers reach the control plane through host.testcontainers.internal + Testcontainers.exposeHostPorts(controlPlane.getPort()); + controlPlane.setSnapshot( + "client-sidecar", + XdsControlPlane.listener("xds/client-sidecar-listener.json", Collections.emptyMap()), + XdsControlPlane.cluster("xds/client-sidecar-cluster.json") + ); + allowCaller(controlPlane, CLIENT_CALLER); + return controlPlane; + } + + private static void allowCaller(XdsControlPlane controlPlane, String caller) { + controlPlane.setSnapshot( + "server-sidecar", + XdsControlPlane.listener( + "xds/server-sidecar-listener.json", + Collections.singletonMap("ALLOWED_CALLER", caller) + ), + XdsControlPlane.cluster("xds/server-sidecar-cluster.json") + ); + } + + private static GenericContainer> sidecar(String nodeId, int trafficPort) { + String bootstrap = XdsControlPlane.render( + "bootstrap.yaml", + Collections.singletonMap("XDS_PORT", String.valueOf(controlPlane.getPort())) + ); + return new GenericContainer<>(ENVOY_IMAGE) + .withNetwork(network) + .withNetworkAliases(nodeId) + .withCopyToContainer(Transferable.of(bootstrap), "/etc/envoy/bootstrap.yaml") + .withCommand("-c", "/etc/envoy/bootstrap.yaml", "--service-node", nodeId, "--service-cluster", nodeId) + .withExposedPorts(trafficPort, ADMIN_PORT) + // /ready returns 200 only after the listeners and clusters were received over xDS + .waitingFor(Wait.forHttp("/ready").forPort(ADMIN_PORT)); + } + + @AfterAll + static void stopControlPlane() { + controlPlane.close(); + } + + @BeforeEach + void resetCounters() throws Exception { + send( + HttpRequest.newBuilder(adminUri(clientSidecar, "/reset_counters")).POST(HttpRequest.BodyPublishers.noBody()) + ); + } + + @Test + void sendsRequestThroughTheSidecars() throws Exception { + HttpResponse
+ * Each sidecar is identified by its Envoy node id and receives its own snapshot of listeners and clusters.
+ * Calling {@link #setSnapshot(String, Listener, Cluster)} again pushes the new configuration to the running sidecar.
+ */
+class XdsControlPlane implements AutoCloseable {
+
+ private static final JsonFormat.Parser JSON_PARSER = JsonFormat
+ .parser()
+ .usingTypeRegistry(
+ JsonFormat.TypeRegistry
+ .newBuilder()
+ .add(HttpConnectionManager.getDescriptor())
+ .add(Router.getDescriptor())
+ .add(RBAC.getDescriptor())
+ .build()
+ );
+
+ private final SimpleCache