diff --git a/dependencies.toml b/dependencies.toml index ee58badbbc9..86299e22392 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -49,6 +49,8 @@ hamcrest = "3.0" hbase = "1.2.6" hibernate-validator6 = "6.2.5.Final" hibernate-validator8 = "8.0.3.Final" +# used by :it:xds-istio +istio = "1.29.1" j2objc = "3.1" jackson = "2.21.2" jakarta-inject = "2.0.1" @@ -1409,6 +1411,8 @@ version.ref = "spring-boot4" module = "org.testcontainers:testcontainers" [libraries.testcontainers-consul] module = "org.testcontainers:testcontainers-consul" +[libraries.testcontainers-k3s] +module = "org.testcontainers:testcontainers-k3s" [libraries.testcontainers-junit-jupiter] module = "org.testcontainers:testcontainers-junit-jupiter" diff --git a/it/xds-istio/build.gradle b/it/xds-istio/build.gradle new file mode 100644 index 00000000000..185ee3f8ea0 --- /dev/null +++ b/it/xds-istio/build.gradle @@ -0,0 +1,143 @@ +dependencies { + implementation project(':junit5') + implementation project(':kubernetes') + implementation libs.junit5.platform.launcher + implementation libs.testcontainers.k3s + implementation libs.testcontainers.junit.jupiter +} + +def kubeconfigEnvValue = + layout.buildDirectory.file('kubeconfig/kubeconfig.yaml').get().asFile.absolutePath +def istioVersion = libs.versions.istio.get() +def istioProfile = 'minimal' +def istioWorkDir = layout.buildDirectory.dir('istio').get().asFile +def istioOs = detectIstioOs() +def istioArch = detectIstioArch() +def istioSupported = istioOs != null && istioArch != null +if (!istioSupported) { + def osDetector = requireOsDetector() + def osValue = osDetector.os + def archValue = osDetector.arch + logger.warn("Istio is not supported on ${osValue}/${archValue}; skipping :it:xds-istio tasks.") + tasks.configureEach { + enabled = false + } + return +} +def istioArchive = file("${istioWorkDir}/istio-${istioVersion}-${istioOs}-${istioArch}.tar.gz") +def istioHomeDir = file("${istioWorkDir}/istio-${istioVersion}") +def istioctlPath = new File(istioHomeDir, 'bin/istioctl').absolutePath +def istioEnv = [ + 'KUBECONFIG_PATH': kubeconfigEnvValue, + 'ISTIO_VERSION': istioVersion, + 'ISTIO_PROFILE': istioProfile, + 'ISTIOCTL_PATH': istioctlPath +] + +tasks.register('downloadIstioctl') { + outputs.file istioArchive + inputs.property 'istioVersion', istioVersion + inputs.property 'istioOs', istioOs + inputs.property 'istioArch', istioArch + doLast { + istioArchive.parentFile.mkdirs() + def url = "https://github.com/istio/istio/releases/download/${istioVersion}/" + + "istio-${istioVersion}-${istioOs}-${istioArch}.tar.gz" + ant.get(src: url, dest: istioArchive, skipexisting: true) + } +} + +tasks.register('extractIstioctl', Copy) { + dependsOn tasks.named('downloadIstioctl') + from tarTree(istioArchive) + into istioWorkDir + outputs.dir istioHomeDir +} + +tasks.register('prepareIstioctl') { + dependsOn tasks.named('extractIstioctl') + outputs.file istioctlPath + doLast { + def istioctlFile = file(istioctlPath) + if (!istioctlFile.exists()) { + throw new GradleException("istioctl was not found at ${istioctlPath}") + } + istioctlFile.setExecutable(true) + } +} + +def testRuntimeDir = new File(istioWorkDir, 'test-runtime-jars') +def dockerImagesDir = new File(istioWorkDir, 'docker-images') +istioEnv['ISTIO_TEST_RUNTIME_DIR'] = testRuntimeDir.absolutePath +istioEnv['ISTIO_DOCKER_IMAGES_DIR'] = dockerImagesDir.absolutePath + +tasks.register('prepareIstioWorkdir') { + outputs.dirs testRuntimeDir, dockerImagesDir + doLast { + testRuntimeDir.mkdirs() + dockerImagesDir.mkdirs() + } +} + +tasks.register('copyTestRuntimeJars', Sync) { + dependsOn tasks.named('prepareIstioWorkdir') + from(sourceSets.test.runtimeClasspath) + into(testRuntimeDir) +} + +tasks.withType(Test).configureEach { + dependsOn tasks.named('copyTestRuntimeJars') + dependsOn tasks.named('prepareIstioctl') + environment istioEnv + systemProperty 'junit.jupiter.execution.parallel.enabled', 'false' + maxParallelForks = 1 + doFirst { + def allArgs = (jvmArgs ?: []).join(' ') + if (allArgs) { + environment 'ISTIO_POD_JVM_ARGS', allArgs + } + } +} + +// For LocalDevClusterMain +tasks.withType(JavaExec).configureEach { + dependsOn tasks.named('prepareIstioctl') + environment istioEnv +} + +def detectIstioOs() { + def osDetector = requireOsDetector() + def os = osDetector.os + if (os == 'osx') { + return 'osx' + } + if (os == 'linux') { + return 'linux' + } + logger.warn("Unsupported OS for Istio: {}", os) + return null +} + +def detectIstioArch() { + def osDetector = requireOsDetector() + def arch = String.valueOf(osDetector.arch).toLowerCase(Locale.ROOT) + if (arch == 'x86_64' || arch == 'amd64') { + return 'amd64' + } + if (arch == 'aarch64' || arch == 'arm64' || arch == 'aarch_64' || arch == 'arm_64') { + return 'arm64' + } + if (arch.startsWith('armv7') || arch == 'armv7' || arch == 'arm_32') { + return 'armv7' + } + logger.warn("Unsupported architecture for Istio: {}", arch) + return null +} + +def requireOsDetector() { + def osDetector = rootProject.extensions.findByName('osdetector') + if (osDetector == null) { + throw new GradleException("osdetector extension is required for :it:xds-istio") + } + return osDetector +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/DockerAvailableCondition.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/DockerAvailableCondition.java new file mode 100644 index 00000000000..84c62be98d8 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/DockerAvailableCondition.java @@ -0,0 +1,35 @@ +/* + * 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.istio.testing; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.DockerClientFactory; + +final class DockerAvailableCondition implements ExecutionCondition { + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + // When running inside a K8s pod, Docker is not available but the test should still run. + if (HostOnlyExtension.isRunningInPod()) { + return ConditionEvaluationResult.enabled("Running inside K8s pod"); + } + final boolean available = DockerClientFactory.instance().isDockerAvailable(); + return available ? + ConditionEvaluationResult.enabled("Docker is available") + : ConditionEvaluationResult.disabled("Docker daemon is not running"); + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/EnabledIfDockerAvailable.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/EnabledIfDockerAvailable.java new file mode 100644 index 00000000000..df4370673b5 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/EnabledIfDockerAvailable.java @@ -0,0 +1,32 @@ +/* + * 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.istio.testing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Enables the annotated test class or method only when the Docker daemon is available, + * or when running inside a Kubernetes pod (where Docker is not needed). + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(DockerAvailableCondition.class) +public @interface EnabledIfDockerAvailable {} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/HostOnlyExtension.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/HostOnlyExtension.java new file mode 100644 index 00000000000..673b925cefb --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/HostOnlyExtension.java @@ -0,0 +1,60 @@ +/* + * 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.istio.testing; + +import org.junit.jupiter.api.extension.ExtensionContext; + +import com.linecorp.armeria.testing.junit5.common.AbstractAllOrEachExtension; + +/** + * Base class for JUnit extensions that must only run when executing on the host, + * not inside a Kubernetes pod. Subclass this instead of {@link AbstractAllOrEachExtension} + * when the extension manages host-side infrastructure (cluster lifecycle, container + * management, etc.) that must not execute inside the in-cluster test job. + * + *
Implement {@link #setUp} and optionally {@link #tearDown}. Both are no-ops + * when {@code RUNNING_IN_K8S_POD=true}. + */ +abstract class HostOnlyExtension extends AbstractAllOrEachExtension { + + static final String RUNNING_IN_K8S_POD_ENV = "RUNNING_IN_K8S_POD"; + + static boolean isRunningInPod() { + return Boolean.parseBoolean(System.getenv(RUNNING_IN_K8S_POD_ENV)); + } + + static boolean notRunningInPod() { + return !isRunningInPod(); + } + + @Override + protected final void before(ExtensionContext context) throws Exception { + if (notRunningInPod()) { + setUp(context); + } + } + + @Override + protected final void after(ExtensionContext context) throws Exception { + if (notRunningInPod()) { + tearDown(context); + } + } + + abstract void setUp(ExtensionContext context) throws Exception; + + void tearDown(ExtensionContext context) throws Exception {} +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioClusterExtension.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioClusterExtension.java new file mode 100644 index 00000000000..0b62ad76e88 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioClusterExtension.java @@ -0,0 +1,156 @@ +/* + * 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.istio.testing; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.common.annotation.Nullable; + +import io.fabric8.kubernetes.client.KubernetesClient; + +/** + * A JUnit extension that manages Kubernetes cluster lifecycle with Istio for testing. + * + *
Default behavior: + *
Example usage: + *
{@code
+ * class MyIstioTest {
+ * @RegisterExtension
+ * static IstioClusterExtension istio = new IstioClusterExtension();
+ *
+ * @Test
+ * void test() {
+ * KubernetesClient client = istio.client();
+ * // Test logic
+ * }
+ * }
+ * }
+ *
+ * For Istio reinstallation on each test: + *
{@code
+ * @RegisterExtension
+ * static IstioClusterExtension istio = IstioClusterExtension.builder()
+ * .runForEachTest(true)
+ * .build();
+ * }
+ */
+public final class IstioClusterExtension extends HostOnlyExtension {
+
+ private static final Logger logger = LoggerFactory.getLogger(IstioClusterExtension.class);
+
+ public static final ExtensionContext.Namespace NAMESPACE =
+ ExtensionContext.Namespace.create(IstioClusterExtension.class);
+ public static final String K8S_CLIENT_KEY = "kubernetesClient";
+
+ @Nullable
+ private IstioState state;
+
+ private final boolean runForEachTest;
+
+ /**
+ * Creates a new instance with default settings.
+ */
+ public IstioClusterExtension() {
+ this(builder());
+ }
+
+ private IstioClusterExtension(Builder builder) {
+ runForEachTest = builder.runForEachTest;
+ }
+
+ @Override
+ protected boolean runForEachTest() {
+ return runForEachTest;
+ }
+
+ /**
+ * Returns a new builder for configuring the extension.
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ @Override
+ void setUp(ExtensionContext context) throws Exception {
+ state = IstioState.connectOrCreate();
+ context.getStore(NAMESPACE).put(K8S_CLIENT_KEY, state.client());
+ }
+
+ @Override
+ void tearDown(ExtensionContext context) throws Exception {
+ if (state != null) {
+ try {
+ state.client().namespaces().withLabel("test-namespace", "true").delete();
+ } catch (Exception e) {
+ logger.warn("Failed to clean up test namespaces", e);
+ }
+ state.close();
+ state = null;
+ }
+ }
+
+ /**
+ * Returns the Kubernetes client for interacting with the cluster.
+ */
+ public KubernetesClient client() {
+ if (state == null) {
+ throw new IllegalStateException("Kubernetes client not initialized. " +
+ "Ensure the extension is properly registered.");
+ }
+ return state.client();
+ }
+
+ /**
+ * Blocks until Istiod is ready in the cluster, or returns {@code false} on timeout.
+ */
+ public boolean waitForIstiodReady() {
+ return IstioInstaller.waitForIstiodReady(client());
+ }
+
+ /**
+ * Builder for configuring {@link IstioClusterExtension}.
+ */
+ public static final class Builder {
+ private boolean runForEachTest;
+
+ private Builder() {}
+
+ /**
+ * Sets whether to run the extension for each test method.
+ * When true, Istio will be reinstalled before each test.
+ * Default is false.
+ */
+ public Builder runForEachTest(boolean runForEachTest) {
+ this.runForEachTest = runForEachTest;
+ return this;
+ }
+
+ /**
+ * Builds the configured {@link IstioClusterExtension}.
+ */
+ public IstioClusterExtension build() {
+ return new IstioClusterExtension(this);
+ }
+ }
+}
diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioEnv.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioEnv.java
new file mode 100644
index 00000000000..9d9100d1dc6
--- /dev/null
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioEnv.java
@@ -0,0 +1,73 @@
+/*
+ * 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.istio.testing;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+final class IstioEnv {
+
+ private static final String KUBECONFIG_PATH_ENV = "KUBECONFIG_PATH";
+ private static final String ISTIO_VERSION_ENV = "ISTIO_VERSION";
+ private static final String ISTIO_PROFILE_ENV = "ISTIO_PROFILE";
+ private static final String ISTIOCTL_PATH_ENV = "ISTIOCTL_PATH";
+ private static final String ISTIO_TEST_RUNTIME_DIR_ENV = "ISTIO_TEST_RUNTIME_DIR";
+ private static final String ISTIO_DOCKER_IMAGES_DIR_ENV = "ISTIO_DOCKER_IMAGES_DIR";
+ private static final String ISTIO_POD_JVM_ARGS_ENV = "ISTIO_POD_JVM_ARGS";
+
+ private IstioEnv() {}
+
+ static Path kubeconfigPath() {
+ return Paths.get(require(KUBECONFIG_PATH_ENV));
+ }
+
+ static String istioVersion() {
+ return require(ISTIO_VERSION_ENV);
+ }
+
+ static String istioProfile() {
+ return require(ISTIO_PROFILE_ENV);
+ }
+
+ static Path testRuntimeDir() {
+ return Paths.get(require(ISTIO_TEST_RUNTIME_DIR_ENV));
+ }
+
+ static Path dockerImagesDir() {
+ return Paths.get(require(ISTIO_DOCKER_IMAGES_DIR_ENV));
+ }
+
+ static Path istioctlPath() {
+ final Path path = Paths.get(require(ISTIOCTL_PATH_ENV));
+ if (!Files.isExecutable(path)) {
+ throw new IllegalStateException("istioctl is not executable: " + path);
+ }
+ return path;
+ }
+
+ static String podJvmArgs() {
+ return require(ISTIO_POD_JVM_ARGS_ENV);
+ }
+
+ private static String require(String name) {
+ final String v = System.getenv(name);
+ if (v == null || v.isBlank()) {
+ throw new IllegalStateException(name + " must be set.");
+ }
+ return v.trim();
+ }
+}
diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioInstaller.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioInstaller.java
new file mode 100644
index 00000000000..aa6713cd249
--- /dev/null
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioInstaller.java
@@ -0,0 +1,211 @@
+/*
+ * 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.istio.testing;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.linecorp.armeria.common.annotation.Nullable;
+
+import io.fabric8.kubernetes.api.model.apps.Deployment;
+import io.fabric8.kubernetes.api.model.apps.DeploymentCondition;
+import io.fabric8.kubernetes.client.KubernetesClient;
+
+final class IstioInstaller {
+
+ static final Duration DEFAULT_READY_TIMEOUT = Duration.ofMinutes(5);
+
+ static final String DEFAULT_NAMESPACE = "istio-system";
+
+ private static final String ISTIO_NAMESPACE_ENV = "ISTIO_NAMESPACE";
+
+ private static final Logger logger = LoggerFactory.getLogger(IstioInstaller.class);
+
+ private IstioInstaller() {}
+
+ // -- Installation --
+
+ static void installIfNeeded(Path kubeconfigPath) throws Exception {
+ installIfNeeded(kubeconfigPath, IstioEnv.istioProfile());
+ }
+
+ static void installIfNeeded(Path kubeconfigPath, String profile) throws Exception {
+ final String version = IstioEnv.istioVersion();
+ final String namespace = istioNamespace();
+
+ try (KubernetesClient client = K8sClusterHelper.createClient(kubeconfigPath)) {
+ if (isIstioInstalled(client, namespace)) {
+ logger.info("Istio is already installed in namespace '{}'.", namespace);
+ if (!waitForIstiodReady(client, namespace, DEFAULT_READY_TIMEOUT,
+ K8sClusterHelper.DEFAULT_POLL_INTERVAL)) {
+ throw new IllegalStateException("Timed out waiting for Istio to be Ready.");
+ }
+ enableNamespaceInjection(client, "default");
+ return;
+ }
+ }
+
+ final Path istioctl = IstioEnv.istioctlPath();
+ logger.info("Installing Istio {} with profile '{}'.", version, profile);
+ runIstioctlInstall(istioctl, kubeconfigPath, profile);
+
+ try (KubernetesClient client = K8sClusterHelper.createClient(kubeconfigPath)) {
+ if (!waitForIstiodReady(client, namespace, DEFAULT_READY_TIMEOUT,
+ K8sClusterHelper.DEFAULT_POLL_INTERVAL)) {
+ throw new IllegalStateException("Timed out waiting for Istio to be Ready.");
+ }
+ enableNamespaceInjection(client, "default");
+ }
+ }
+
+ private static void enableNamespaceInjection(KubernetesClient client, String namespaceName) {
+ client.namespaces().withName(namespaceName).edit(ns -> {
+ ns.getMetadata().getLabels().put("istio-injection", "enabled");
+ return ns;
+ });
+ logger.info("Labeled namespace '{}' with istio-injection=enabled", namespaceName);
+ }
+
+ private static void runIstioctlInstall(Path istioctl, Path kubeconfigPath,
+ String profile) throws Exception {
+ final Path istioctlDir = requireParent(istioctl, "istioctl");
+ runCommand(List.of(istioctl.toString(),
+ "install",
+ "--set", "profile=" + profile,
+ "--skip-confirmation",
+ "--kubeconfig", kubeconfigPath.toAbsolutePath().toString()),
+ istioctlDir,
+ "istioctl");
+ }
+
+ static void runIstioctlUninstall(Path kubeconfigPath) throws Exception {
+ final Path istioctl = IstioEnv.istioctlPath();
+ final Path istioctlDir = requireParent(istioctl, "istioctl");
+ runCommand(List.of(istioctl.toString(),
+ "uninstall",
+ "--purge",
+ "-y",
+ "--kubeconfig", kubeconfigPath.toAbsolutePath().toString()),
+ istioctlDir,
+ "istioctl");
+ }
+
+ private static void runCommand(ListThis is the default value for {@link IstioPodTest#podCustomizer()}. + */ +public final class IstioPodCustomizer implements PodCustomizer { + + @Override + public void customizePod(PodBuilder podBuilder) { + podBuilder.editMetadata() + .addToAnnotations("sidecar.istio.io/inject", "true") + .endMetadata() + .editSpec() + .editMatchingContainer(c -> "test".equals(c.getName())) + // Mount volumes injected by the Istio mutating webhook so that + // the test container can access pilot-agent's UDS sockets. + .addNewVolumeMount() + .withName("workload-socket") + .withMountPath("/var/run/secrets/workload-spiffe-uds") + .endVolumeMount() + .addNewVolumeMount() + .withName("istio-envoy") + .withMountPath("/etc/istio/proxy") + .endVolumeMount() + .endContainer() + .endSpec(); + } + + @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 — sidecar 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/IstioPodEntryPoint.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodEntryPoint.java new file mode 100644 index 00000000000..6e63795d8a5 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodEntryPoint.java @@ -0,0 +1,96 @@ +/* + * 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.istio.testing; + +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.linecorp.armeria.server.Server; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.ServerConfigurator; + +/** + * Entry point for the shaded workload jar. Runs a single JUnit test method by class and method name, + * then exits with code 0 on success or 1 on failure. + * + *
Usage: {@code java -jar workload.jar --class The enclosing test class must register {@link IstioClusterExtension}:
+ * To use a custom {@link PodCustomizer}, specify the class:
+ * Requires {@link IstioClusterExtension} to be registered on the same test class.
+ *
+ * Example usage:
+ * Requires {@link IstioClusterExtension} to be registered on the same test class so
+ * the {@link KubernetesClient} and K3s container are available in the {@link ExtensionContext.Store}.
+ */
+public final class IstioTestExtension implements InvocationInterceptor {
+
+ private static final Logger logger = LoggerFactory.getLogger(IstioTestExtension.class);
+
+ @Override
+ public void interceptTestMethod(Invocation K3s runs its own containerd instance inside a Docker container, completely isolated
+ * from the host Docker daemon where {@link #build()} places the image. The only way to
+ * bridge the two is to serialize the image out of Docker ({@code docker save}), copy the
+ * tar into the K3s container, and import it into containerd ({@code ctr images import}).
+ */
+ static void loadIntoK3s(K3sContainer k3sContainer) throws Exception {
+ logger.info("Loading {} into K3s cluster...", IMAGE_NAME);
+ build().get();
+
+ final Path tempFile = Files.createTempFile(IstioEnv.dockerImagesDir(), "armeria-istio-test-", ".tar");
+ try {
+ final Process saveProcess = new ProcessBuilder("docker", "save", IMAGE_NAME)
+ .redirectOutput(tempFile.toFile())
+ .start();
+ final int exitCode = saveProcess.waitFor();
+ if (exitCode != 0) {
+ final String stderr = new String(saveProcess.getErrorStream().readAllBytes());
+ throw new IllegalStateException(
+ "docker save exited with status " + exitCode + ": " + stderr);
+ }
+
+ // K3s's containerd cannot reach the host Docker socket, so we copy the tar in
+ // and import it directly into the k8s.io containerd namespace.
+ k3sContainer.copyFileToContainer(
+ MountableFile.forHostPath(tempFile.toAbsolutePath().toString()),
+ "/tmp/armeria-istio-test.tar");
+
+ final ExecResult importResult =
+ k3sContainer.execInContainer(
+ "ctr",
+ "--address", "/run/k3s/containerd/containerd.sock",
+ "--namespace", "k8s.io",
+ "images", "import", "/tmp/armeria-istio-test.tar");
+ if (importResult.getExitCode() != 0) {
+ throw new IllegalStateException(
+ "k3s ctr images import failed: " + importResult.getStderr());
+ }
+ logger.info("Image {} loaded into K3s.", IMAGE_NAME);
+ } finally {
+ Files.deleteIfExists(tempFile);
+ }
+ }
+
+ private IstioTestImage() {}
+}
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
new file mode 100644
index 00000000000..0540ab84467
--- /dev/null
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/K8sClusterHelper.java
@@ -0,0 +1,141 @@
+/*
+ * 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.istio.testing;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.List;
+import java.util.function.BooleanSupplier;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.testcontainers.k3s.K3sContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import io.fabric8.kubernetes.api.model.Node;
+import io.fabric8.kubernetes.api.model.NodeCondition;
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+
+final class K8sClusterHelper {
+
+ private static final Logger k3sLogger = LoggerFactory.getLogger("com.linecorp.armeria.k3s.logger");
+
+ static final Duration DEFAULT_READY_TIMEOUT = Duration.ofMinutes(3);
+ static final Duration DEFAULT_POLL_INTERVAL = Duration.ofSeconds(1);
+
+ private static final String K3S_IMAGE = "rancher/k3s:v1.30.0-k3s1";
+
+ private K8sClusterHelper() {}
+
+ static K3sContainer startK3s() {
+ final K3sContainer k3s = new K3sContainer(DockerImageName.parse(K3S_IMAGE));
+ // uncomment for debugging
+ // .withLogConsumer(new Slf4jLogConsumer(k3sLogger).withPrefix("k3s"));
+ k3s.start();
+ return k3s;
+ }
+
+ static K3sContainer startK3sAndWaitReady() throws Exception {
+ return startK3sAndWaitReady(DEFAULT_READY_TIMEOUT, DEFAULT_POLL_INTERVAL);
+ }
+
+ static K3sContainer startK3sAndWaitReady(Duration timeout, Duration pollInterval) throws Exception {
+ final K3sContainer k3s = startK3s();
+ try {
+ try (KubernetesClient client = createClient(k3s.getKubeConfigYaml())) {
+ if (!waitForReadyNode(client, timeout, pollInterval)) {
+ k3sLogger.warn("Failed to start K3s cluster within timeout: {}", k3s.getLogs());
+ throw new IllegalStateException("Timed out waiting for K3s cluster to be Ready.");
+ }
+ }
+ IstioTestImage.loadIntoK3s(k3s);
+ } catch (Exception e) {
+ k3s.stop();
+ throw e;
+ }
+ return k3s;
+ }
+
+ static KubernetesClient createClient(Path kubeconfigPath) throws IOException {
+ return createClient(Files.readString(kubeconfigPath));
+ }
+
+ 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();
+ }
+
+ static boolean poll(Duration timeout, Duration interval, BooleanSupplier condition) {
+ final long deadlineNanos = System.nanoTime() + timeout.toNanos();
+ while (true) {
+ if (condition.getAsBoolean()) {
+ return true;
+ }
+ if (System.nanoTime() >= deadlineNanos) {
+ return false;
+ }
+ sleep(interval);
+ if (Thread.currentThread().isInterrupted()) {
+ return false;
+ }
+ }
+ }
+
+ private static boolean waitForReadyNode(KubernetesClient client, Duration timeout, Duration pollInterval) {
+ return poll(timeout, pollInterval, () -> hasReadyNodeSafely(client));
+ }
+
+ private static boolean hasReadyNodeSafely(KubernetesClient client) {
+ try {
+ return hasReadyNode(client);
+ } catch (RuntimeException e) {
+ return false;
+ }
+ }
+
+ private static boolean hasReadyNode(KubernetesClient client) {
+ final List Specify the implementation via {@link IstioPodTest#podCustomizer()}.
+ * The default is {@link IstioPodCustomizer}.
+ */
+public interface PodCustomizer {
+
+ /**
+ * Applies customizations to the pod under construction.
+ * The base pod already contains a container named {@code "test"} with the test entry point.
+ */
+ void customizePod(PodBuilder podBuilder);
+
+ /**
+ * Returns {@code true} if the pod is healthy enough for the test container to start running.
+ * The default implementation simply checks that the pod phase is {@code Running}.
+ */
+ default boolean isPodHealthy(Pod pod) {
+ return pod.getStatus() != null && "Running".equals(pod.getStatus().getPhase());
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioImageBuildTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioImageBuildTest.java
new file mode 100644
index 00000000000..5f2db467f6c
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioImageBuildTest.java
@@ -0,0 +1,27 @@
+/*
+ * 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.istio.testing;
+
+import org.junit.jupiter.api.Test;
+
+@EnabledIfDockerAvailable
+class IstioImageBuildTest {
+
+ @Test
+ void dockerImageBuildsSuccessfully() {
+ IstioTestImage.build().get();
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioStartupTest.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioStartupTest.java
new file mode 100644
index 00000000000..1729d10e26b
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioStartupTest.java
@@ -0,0 +1,33 @@
+/*
+ * 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 static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+@EnabledIfDockerAvailable
+class IstioStartupTest {
+
+ @RegisterExtension
+ static IstioClusterExtension cluster = new IstioClusterExtension();
+
+ @Test
+ void istioIsReady() {
+ assertThat(cluster.waitForIstiodReady()).isTrue();
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioTestingBlockHoundIntegration.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioTestingBlockHoundIntegration.java
new file mode 100644
index 00000000000..8f32164d46b
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/istio/testing/IstioTestingBlockHoundIntegration.java
@@ -0,0 +1,27 @@
+/*
+ * 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 reactor.blockhound.BlockHound.Builder;
+import reactor.blockhound.integration.BlockHoundIntegration;
+
+public class IstioTestingBlockHoundIntegration implements BlockHoundIntegration {
+ @Override
+ public void applyTo(Builder builder) {
+ // TODO: Remove this once https://github.com/eclipse-vertx/vert.x/pull/5637 is released.
+ builder.allowBlockingCallsInside("io.netty.util.concurrent.FastThreadLocalRunnable", "run");
+ }
+}
diff --git a/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EchoConfigurator.java b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EchoConfigurator.java
new file mode 100644
index 00000000000..de7027ad8a0
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EchoConfigurator.java
@@ -0,0 +1,33 @@
+/*
+ * 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.common.HttpResponse;
+import com.linecorp.armeria.server.ServerBuilder;
+import com.linecorp.armeria.server.ServerConfigurator;
+import com.linecorp.armeria.server.logging.LoggingService;
+
+/**
+ * Simple echo {@link ServerConfigurator} shared across Istio integration tests.
+ * Responds with {@code "hello"} on {@code GET /echo}.
+ */
+public final class EchoConfigurator implements ServerConfigurator {
+ @Override
+ public void reconfigure(ServerBuilder sb) {
+ sb.service("/echo", (ctx, req) -> HttpResponse.of("hello"));
+ sb.decorator(LoggingService.newDecorator());
+ }
+}
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
new file mode 100644
index 00000000000..6f9ffe839ff
--- /dev/null
+++ b/it/xds-istio/src/test/java/com/linecorp/armeria/it/xds/EnvoyDebugTest.java
@@ -0,0 +1,103 @@
+/*
+ * 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 java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+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.linecorp.armeria.client.WebClient;
+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;
+
+/**
+ * Verifies that {@link IstioServerExtension} correctly deploys a server workload into the
+ * K3s cluster using a {@link com.linecorp.armeria.server.ServerConfigurator} class, and that
+ * the server is reachable from a test pod running inside the same cluster.
+ */
+@EnabledIfDockerAvailable
+class EnvoyDebugTest {
+
+ private static final Logger logger = LoggerFactory.getLogger(EnvoyDebugTest.class);
+
+ static final int PORT = 8080;
+
+ @RegisterExtension
+ @Order(1)
+ static IstioClusterExtension cluster = new IstioClusterExtension();
+
+ @RegisterExtension
+ @Order(2)
+ static IstioServerExtension echo = new IstioServerExtension(
+ "echo-server", PORT, EchoConfigurator.class);
+
+ @IstioPodTest
+ void serverIsReachable() {
+ final WebClient client = WebClient.of("http://" + echo.serviceName() + ':' + echo.port());
+ final AggregatedHttpResponse response = client.get("/echo").aggregate().join();
+ assertThat(response.status()).isEqualTo(HttpStatus.OK);
+ assertThat(response.contentUtf8()).isEqualTo("hello");
+ }
+
+ @IstioPodTest
+ void envoyStatsAreReachable() {
+ final WebClient envoyAdmin = WebClient.of("http://localhost:15000");
+ final AggregatedHttpResponse response = envoyAdmin.get("/stats").aggregate().join();
+ assertThat(response.status()).isEqualTo(HttpStatus.OK);
+ assertThat(response.contentUtf8()).contains("server.state");
+ }
+
+ @IstioPodTest
+ void envoyConfigDump() {
+ final WebClient envoyAdmin = WebClient.of("http://localhost:15000");
+ final AggregatedHttpResponse response = envoyAdmin.get("/config_dump").aggregate().join();
+ assertThat(response.status()).isEqualTo(HttpStatus.OK);
+ logger.info("Envoy config dump: {}", response.contentUtf8());
+ }
+
+ @IstioPodTest
+ void envoyBootstrapFile() throws Exception {
+ final Path dir = Paths.get("/etc/istio/proxy");
+ final List{@code
+ * class MyTest {
+ * @RegisterExtension
+ * static IstioClusterExtension istio = new IstioClusterExtension();
+ *
+ * @IstioPodTest
+ * void myTest() {
+ * // runs inside the K8s Job
+ * }
+ * }
+ * }
+ *
+ * {@code
+ * @IstioPodTest(podCustomizer = MyCustomizer.class)
+ * void myTest() { ... }
+ * }
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Test
+@ExtendWith(IstioTestExtension.class)
+public @interface IstioPodTest {
+
+ /**
+ * The {@link PodCustomizer} class to use when creating the test pod.
+ * The class must have a public no-arg constructor.
+ * Defaults to {@link IstioPodCustomizer}.
+ */
+ Class extends PodCustomizer> podCustomizer() default IstioPodCustomizer.class;
+}
diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java
new file mode 100644
index 00000000000..6ad0bb984c1
--- /dev/null
+++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioServerExtension.java
@@ -0,0 +1,236 @@
+/*
+ * 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.istio.testing;
+
+import static java.util.Objects.requireNonNull;
+
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.linecorp.armeria.server.ServerConfigurator;
+
+import io.fabric8.kubernetes.api.model.IntOrString;
+import io.fabric8.kubernetes.api.model.Pod;
+import io.fabric8.kubernetes.api.model.ServiceBuilder;
+import io.fabric8.kubernetes.api.model.apps.DeploymentBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+
+/**
+ * JUnit 5 extension that deploys a server workload into the K3s cluster using a
+ * {@link ServerConfigurator} class to configure the server. The deployment runs
+ * {@link IstioPodEntryPoint} with {@code --server-factory} and {@code --port} arguments,
+ * which instantiates and invokes the given configurator class inside the pod.
+ * The configurator class must have an accessible no-arg constructor.
+ *
+ * {@code
+ * @Order(1)
+ * @RegisterExtension
+ * static IstioClusterExtension cluster = new IstioClusterExtension();
+ *
+ * @Order(2)
+ * @RegisterExtension
+ * static IstioServerExtension echo = new IstioServerExtension("echo", 8080, EchoConfigurator.class);
+ * }
+ */
+public final class IstioServerExtension extends HostOnlyExtension {
+
+ private static final Logger logger = LoggerFactory.getLogger(IstioServerExtension.class);
+
+ private static final String NAMESPACE = "default";
+ private static final Duration READY_TIMEOUT = Duration.ofMinutes(3);
+
+ private final String serviceName;
+ private final int port;
+ private final Class extends ServerConfigurator> configuratorClass;
+
+ public IstioServerExtension(String serviceName, int port,
+ Class extends ServerConfigurator> configuratorClass) {
+ this.serviceName = requireNonNull(serviceName, "serviceName");
+ if (port <= 0 || port > 65535) {
+ throw new IllegalArgumentException("port: " + port + " (expected: 1-65535)");
+ }
+ this.port = port;
+ this.configuratorClass = requireNonNull(configuratorClass, "configuratorClass");
+ }
+
+ /**
+ * Returns the Kubernetes service name.
+ */
+ public String serviceName() {
+ return serviceName;
+ }
+
+ /**
+ * Returns the port the server listens on.
+ */
+ public int port() {
+ return port;
+ }
+
+ @Override
+ void setUp(ExtensionContext context) throws Exception {
+ final ExtensionContext.Store store = context.getStore(IstioClusterExtension.NAMESPACE);
+
+ final KubernetesClient client = store.get(IstioClusterExtension.K8S_CLIENT_KEY,
+ KubernetesClient.class);
+ if (client == null) {
+ throw new IllegalStateException(
+ "KubernetesClient not found in store. " +
+ "Ensure IstioClusterExtension is registered on this test class.");
+ }
+
+ createDeployment(client);
+ createService(client);
+ waitForReady(client);
+ }
+
+ @Override
+ void tearDown(ExtensionContext context) throws Exception {
+ final ExtensionContext.Store store = context.getStore(IstioClusterExtension.NAMESPACE);
+ final KubernetesClient client = store.get(IstioClusterExtension.K8S_CLIENT_KEY,
+ KubernetesClient.class);
+ if (client == null) {
+ return;
+ }
+ collectServerPodLogs(client);
+ client.apps().deployments().inNamespace(NAMESPACE).withName(serviceName).delete();
+ client.services().inNamespace(NAMESPACE).withName(serviceName).delete();
+ logger.info("Deleted deployment and service '{}'", serviceName);
+ }
+
+ private void collectServerPodLogs(KubernetesClient client) {
+ try {
+ client.pods().inNamespace(NAMESPACE)
+ .withLabel("app", serviceName)
+ .list().getItems()
+ .forEach(pod -> {
+ final String podName = pod.getMetadata().getName();
+ pod.getSpec().getContainers().forEach(c -> {
+ try {
+ final String logs = client.pods().inNamespace(NAMESPACE)
+ .withName(podName)
+ .inContainer(c.getName()).getLog();
+ logger.info("=== Pod '{}' container '{}' logs ===\n{}",
+ podName, c.getName(), logs);
+ } catch (Exception e) {
+ logger.debug("Failed to get logs for pod '{}' container '{}'",
+ podName, c.getName());
+ }
+ });
+ });
+ } catch (Exception e) {
+ logger.warn("Failed to collect server pod logs for '{}'", serviceName, e);
+ }
+ }
+
+ private void createDeployment(KubernetesClient client) {
+ final Map