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(List command, Path workDir, String logPrefix) throws Exception { + final ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(workDir.toFile()); + builder.redirectErrorStream(true); + + final Process process = builder.start(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + logger.info("[{}] {}", logPrefix, line); + } + } + final boolean finished = process.waitFor(DEFAULT_READY_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IllegalStateException("Command timed out after " + DEFAULT_READY_TIMEOUT + + " minutes: " + command); + } + final int exitCode = process.exitValue(); + if (exitCode != 0) { + throw new IllegalStateException("Command failed (" + exitCode + "): " + command); + } + } + + // -- Status / waiting -- + + private static boolean isIstioInstalled(KubernetesClient client, String namespace) { + return client.apps().deployments().inNamespace(namespace).withName("istiod").get() != null; + } + + static boolean waitForIstiodReady(KubernetesClient client) { + return waitForIstiodReady(client, istioNamespace(), DEFAULT_READY_TIMEOUT, + K8sClusterHelper.DEFAULT_POLL_INTERVAL); + } + + private static boolean waitForIstiodReady(KubernetesClient client, String namespace, + Duration timeout, Duration pollInterval) { + return K8sClusterHelper.poll(timeout, pollInterval, + () -> isDeploymentReady(client, namespace, "istiod")); + } + + static boolean waitForIstioRemoval(KubernetesClient client) { + return waitForIstioRemoval(client, istioNamespace(), DEFAULT_READY_TIMEOUT, + K8sClusterHelper.DEFAULT_POLL_INTERVAL); + } + + static boolean waitForIstioRemoval(KubernetesClient client, String namespace, + Duration timeout, Duration pollInterval) { + return K8sClusterHelper.poll(timeout, pollInterval, () -> { + if (isIstioInstalled(client, namespace)) { + return false; + } + return client.namespaces().withName(namespace).get() == null || + client.pods().inNamespace(namespace).list().getItems().isEmpty(); + }); + } + + private static boolean isDeploymentReady(KubernetesClient client, String namespace, String name) { + final Deployment deployment = client.apps().deployments().inNamespace(namespace).withName(name).get(); + if (deployment == null || deployment.getStatus() == null) { + return false; + } + if (Boolean.TRUE.equals(hasAvailableCondition(deployment))) { + return true; + } + final Integer availableReplicas = deployment.getStatus().getAvailableReplicas(); + return availableReplicas != null && availableReplicas > 0; + } + + @Nullable + private static Boolean hasAvailableCondition(Deployment deployment) { + if (deployment.getStatus() == null || deployment.getStatus().getConditions() == null) { + return null; + } + for (DeploymentCondition condition : deployment.getStatus().getConditions()) { + if ("Available".equals(condition.getType()) && "True".equals(condition.getStatus())) { + return true; + } + } + return null; + } + + // -- Configuration / utilities -- + + private static String istioNamespace() { + final String v = System.getenv(ISTIO_NAMESPACE_ENV); + return (v != null && !v.isBlank()) ? v.trim() : DEFAULT_NAMESPACE; + } + + private static Path requireParent(Path path, String description) { + final Path parent = path.getParent(); + if (parent == null) { + throw new IllegalStateException("Missing parent directory for " + description + ": " + path); + } + return parent; + } +} 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 new file mode 100644 index 00000000000..a222443de7c --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodCustomizer.java @@ -0,0 +1,69 @@ +/* + * 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 io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; + +/** + * Default {@link PodCustomizer} for Istio-injected pods. Adds the Istio sidecar injection + * annotation and mounts the UDS socket volumes that {@code pilot-agent} exposes, so the + * test container can reach the xDS and SDS APIs. + * + *

This 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 --method } + */ +public final class IstioPodEntryPoint { + + private static final Logger logger = LoggerFactory.getLogger(IstioPodEntryPoint.class); + + public static void main(String[] args) throws Exception { + String className = null; + String methodName = null; + String serverFactory = null; + int serverPort = -1; + for (int i = 0; i < args.length - 1; i++) { + if ("--class".equals(args[i])) { + className = args[i + 1]; + } else if ("--method".equals(args[i])) { + methodName = args[i + 1]; + } else if ("--server-factory".equals(args[i])) { + serverFactory = args[i + 1]; + } else if ("--port".equals(args[i])) { + serverPort = Integer.parseInt(args[i + 1]); + } + } + + if (serverFactory != null) { + if (serverPort < 0) { + logger.error("--port is required with --server-factory"); + System.exit(2); + } + final ServerConfigurator configurator = + (ServerConfigurator) Class.forName(serverFactory) + .getDeclaredConstructor() + .newInstance(); + final ServerBuilder sb = Server.builder().http(serverPort); + configurator.reconfigure(sb); + sb.build().start().join(); + Thread.currentThread().join(); + return; + } + + if (className == null || methodName == null) { + logger.error("Usage: IstioPodEntryPoint --class --method "); + System.exit(2); + } + + final LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectMethod(className, methodName)) + .build(); + final SummaryGeneratingListener listener = new SummaryGeneratingListener(); + final Launcher launcher = LauncherFactory.create(); + launcher.execute(request, listener); + + final long failures = listener.getSummary().getFailures().size(); + if (failures > 0) { + listener.getSummary().getFailures().forEach(f -> + logger.error("Test failed", f.getException())); + System.exit(1); + } + System.exit(0); + } + + private IstioPodEntryPoint() {} +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodTest.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodTest.java new file mode 100644 index 00000000000..0d9bc4a75df --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioPodTest.java @@ -0,0 +1,62 @@ +/* + * 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.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Marks a test method to run inside a Kubernetes Job in the Istio-enabled K3s cluster + * rather than locally. The local test body is skipped; the extension submits a Job, + * waits for it, and propagates success or failure back to JUnit. + * + *

The enclosing test class must register {@link IstioClusterExtension}: + *

{@code
+ * class MyTest {
+ *     @RegisterExtension
+ *     static IstioClusterExtension istio = new IstioClusterExtension();
+ *
+ *     @IstioPodTest
+ *     void myTest() {
+ *         // runs inside the K8s Job
+ *     }
+ * }
+ * }
+ * + *

To use a custom {@link PodCustomizer}, specify the class: + *

{@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 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. + * + *

Requires {@link IstioClusterExtension} to be registered on the same test class. + * + *

Example usage: + *

{@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 configuratorClass; + + public IstioServerExtension(String serviceName, int port, + Class 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 labels = Map.of("app", serviceName); + client.apps().deployments().inNamespace(NAMESPACE) + .resource(new DeploymentBuilder() + .withNewMetadata() + .withName(serviceName) + .withNamespace(NAMESPACE) + .endMetadata() + .withNewSpec() + .withReplicas(1) + .withNewSelector() + .withMatchLabels(labels) + .endSelector() + .withNewTemplate() + .withNewMetadata() + .withLabels(labels) + .withAnnotations(Map.of("sidecar.istio.io/inject", "true")) + .endMetadata() + .withNewSpec() + .addNewContainer() + .withName("server") + .withImage(IstioTestImage.IMAGE_NAME) + .withImagePullPolicy("Never") + .withArgs("--server-factory", configuratorClass.getName(), + "--port", String.valueOf(port)) + .addNewEnv() + .withName("JAVA_TOOL_OPTIONS") + .withValue(IstioEnv.podJvmArgs()) + .endEnv() + .endContainer() + .endSpec() + .endTemplate() + .endSpec() + .build()) + .create(); + logger.info("Created deployment '{}' with server-factory '{}'", + serviceName, configuratorClass.getName()); + } + + private void createService(KubernetesClient client) { + client.services().inNamespace(NAMESPACE) + .resource(new ServiceBuilder() + .withNewMetadata() + .withName(serviceName) + .withNamespace(NAMESPACE) + .endMetadata() + .withNewSpec() + .withSelector(Map.of("app", serviceName)) + .addNewPort() + .withPort(port) + .withTargetPort(new IntOrString(port)) + .endPort() + .endSpec() + .build()) + .create(); + logger.info("Created service '{}' on port {}", serviceName, port); + } + + private void waitForReady(KubernetesClient client) { + logger.info("Waiting for deployment '{}' to be ready...", serviceName); + final boolean ready = K8sClusterHelper.poll( + READY_TIMEOUT, K8sClusterHelper.DEFAULT_POLL_INTERVAL, () -> { + final List pods = client.pods().inNamespace(NAMESPACE) + .withLabel("app", serviceName) + .list() + .getItems(); + return pods.stream().anyMatch(pod -> { + if (pod.getStatus() == null || !"Running".equals(pod.getStatus().getPhase())) { + return false; + } + final var statuses = pod.getStatus().getContainerStatuses(); + if (statuses == null) { + return false; + } + final boolean serverReady = statuses.stream() + .filter(cs -> "server".equals(cs.getName())) + .anyMatch(cs -> Boolean.TRUE.equals(cs.getReady())); + final boolean proxyReady = statuses.stream() + .filter(cs -> "istio-proxy".equals(cs.getName())) + .anyMatch(cs -> Boolean.TRUE.equals(cs.getReady())); + return serverReady && proxyReady; + }); + }); + if (!ready) { + throw new IllegalStateException( + "Timed out waiting for deployment '" + serviceName + "' to become ready"); + } + logger.info("Deployment '{}' is ready.", serviceName); + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioState.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioState.java new file mode 100644 index 00000000000..df40405197e --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioState.java @@ -0,0 +1,111 @@ +/* + * 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.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.k3s.K3sContainer; + +import com.linecorp.armeria.common.util.SafeCloseable; + +import io.fabric8.kubernetes.client.KubernetesClient; + +final class IstioState implements SafeCloseable { + + private static final Logger logger = LoggerFactory.getLogger(IstioState.class); + + private final KubernetesClient client; + private final Path kubeconfigPath; + + private IstioState(KubernetesClient client, Path kubeconfigPath) { + this.client = client; + this.kubeconfigPath = kubeconfigPath; + } + + static IstioState connectOrCreate() throws Exception { + final Path kubeconfigPath = IstioEnv.kubeconfigPath(); + final KubernetesClient client; + + if (Files.exists(kubeconfigPath)) { + final KubernetesClient existing = K8sClusterHelper.createClient(kubeconfigPath); + KubernetesClient connected = null; + try { + existing.namespaces().list(); + logger.info("Successfully connected to existing K3s cluster"); + connected = existing; + } catch (Exception e) { + logger.warn("Failed to connect to existing cluster, creating a new one", e); + existing.close(); + } + if (connected != null) { + client = connected; + } else { + client = startFreshCluster(kubeconfigPath); + } + } else { + logger.info("No kubeconfig found, creating new cluster"); + client = startFreshCluster(kubeconfigPath); + } + + reinstallIstio(kubeconfigPath, client); + return new IstioState(client, kubeconfigPath); + } + + KubernetesClient client() { + return client; + } + + Path kubeconfigPath() { + return kubeconfigPath; + } + + @Override + public void close() { + client.close(); + } + + private static KubernetesClient startFreshCluster(Path kubeconfigPath) throws Exception { + final Path parent = kubeconfigPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + logger.info("Starting new K3s cluster..."); + final K3sContainer k3sContainer = K8sClusterHelper.startK3sAndWaitReady(); + Files.writeString(kubeconfigPath, k3sContainer.getKubeConfigYaml(), StandardCharsets.UTF_8); + logger.info("K3s cluster started with container ID: {}", k3sContainer.getContainerId()); + return K8sClusterHelper.createClient(kubeconfigPath); + } + + private static void reinstallIstio(Path kubeconfigPath, KubernetesClient client) throws Exception { + logger.info("Uninstalling existing Istio installation..."); + IstioInstaller.runIstioctlUninstall(kubeconfigPath); + + logger.info("Waiting for Istio resources to be removed..."); + if (!IstioInstaller.waitForIstioRemoval(client)) { + throw new IllegalStateException("Timed out waiting for Istio to be removed"); + } + + IstioInstaller.installIfNeeded(kubeconfigPath); + + if (!IstioInstaller.waitForIstiodReady(client)) { + throw new IllegalStateException("Istio failed to become ready after reinstallation"); + } + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestExtension.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestExtension.java new file mode 100644 index 00000000000..d7378f23840 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestExtension.java @@ -0,0 +1,225 @@ +/* + * 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.reflect.Method; +import java.time.Duration; +import java.util.UUID; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; + +/** + * JUnit 5 extension that intercepts {@link IstioPodTest}-annotated methods and runs them + * inside a Kubernetes Job in the K3s cluster instead of locally. + * + *

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 invocation, + ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + // When running inside the K8s Job itself, execute the test body normally. + if (Boolean.parseBoolean(System.getenv(HostOnlyExtension.RUNNING_IN_K8S_POD_ENV))) { + invocation.proceed(); + return; + } + + invocation.skip(); + + final ExtensionContext classContext = extensionContext.getParent() + .orElseThrow(() -> new IllegalStateException("No parent context found")); + final ExtensionContext.Store store = classContext.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."); + } + + // Instantiate the PodCustomizer specified in the @IstioPodTest annotation. + final IstioPodTest annotation = invocationContext.getExecutable() + .getAnnotation(IstioPodTest.class); + final PodCustomizer podCustomizer; + try { + final Class customizerClass = + annotation != null ? annotation.podCustomizer() : IstioPodCustomizer.class; + podCustomizer = customizerClass.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalStateException("Failed to instantiate PodCustomizer", e); + } + + final String testClass = invocationContext.getTargetClass().getName(); + final String testMethod = invocationContext.getExecutable().getName(); + final String namespace = "default"; + final String podName = createTestPod(client, namespace, testClass, testMethod, podCustomizer); + + logger.info("Created K8s Pod '{}' for {}.{}", podName, testClass, testMethod); + + try { + waitForPodHealthy(client, podName, namespace, podCustomizer); + final int exitCode = waitForTestContainerTerminated(client, podName, namespace); + final String logs = collectPodLogs(client, podName, namespace); + if (exitCode == 0) { + logger.info("Pod '{}' succeeded for {}.{}\nPod logs:\n{}", + podName, testClass, testMethod, logs); + } else { + logger.error("Pod '{}' failed (exit {}) for {}.{}\nPod logs:\n{}", + podName, exitCode, testClass, testMethod, logs); + final String serverLogs = collectServerPodLogs(client, namespace); + throw new AssertionError( + "Istio test pod failed for " + testClass + "#" + testMethod + + "\nPod logs:\n" + logs + + "\nServer pod logs:\n" + serverLogs); + } + } finally { + client.pods().inNamespace(namespace).withName(podName).delete(); + } + } + + private static String createTestPod(KubernetesClient client, String namespace, + String testClass, String testMethod, + PodCustomizer podCustomizer) { + final String podName = "istio-test-" + + UUID.randomUUID().toString().replace("-", "").substring(0, 16); + + final PodBuilder builder = new PodBuilder() + .withNewMetadata() + .withName(podName) + .withNamespace(namespace) + .endMetadata() + .withNewSpec() + .withRestartPolicy("Never") + .addNewContainer() + .withName("test") + .withImage(IstioTestImage.IMAGE_NAME) + .withImagePullPolicy("Never") + .withArgs("--class", testClass, "--method", testMethod) + .addNewEnv() + .withName(HostOnlyExtension.RUNNING_IN_K8S_POD_ENV) + .withValue("true") + .endEnv() + .addNewEnv() + .withName("JAVA_TOOL_OPTIONS") + .withValue(IstioEnv.podJvmArgs()) + .endEnv() + .endContainer() + .endSpec(); + + podCustomizer.customizePod(builder); + + client.pods().inNamespace(namespace).resource(builder.build()).create(); + return podName; + } + + private static void waitForPodHealthy(KubernetesClient client, + String podName, String namespace, + PodCustomizer podCustomizer) { + final boolean healthy = K8sClusterHelper.poll( + Duration.ofMinutes(5), K8sClusterHelper.DEFAULT_POLL_INTERVAL, () -> { + final Pod pod = client.pods().inNamespace(namespace).withName(podName).get(); + if (pod == null || pod.getStatus() == null) { + return false; + } + return podCustomizer.isPodHealthy(pod); + }); + if (!healthy) { + final String logs = collectPodLogs(client, podName, namespace); + throw new IllegalStateException( + "Timed out waiting for pod '" + podName + "' to become healthy\nPod logs:\n" + logs); + } + logger.info("Pod '{}' is healthy", podName); + } + + private static int waitForTestContainerTerminated(KubernetesClient client, + String podName, String namespace) { + final int[] exitCode = {1}; + final boolean terminated = K8sClusterHelper.poll( + Duration.ofMinutes(5), K8sClusterHelper.DEFAULT_POLL_INTERVAL, () -> { + final Pod pod = client.pods().inNamespace(namespace).withName(podName).get(); + if (pod == null || pod.getStatus() == null || + pod.getStatus().getContainerStatuses() == null) { + return false; + } + return pod.getStatus().getContainerStatuses().stream() + .filter(cs -> "test".equals(cs.getName())) + .filter(cs -> cs.getState() != null && cs.getState().getTerminated() != null) + .findFirst() + .map(cs -> { + exitCode[0] = cs.getState().getTerminated().getExitCode(); + return true; + }) + .orElse(false); + }); + if (!terminated) { + logger.warn("Timed out waiting for test container in pod '{}' to terminate", podName); + } + return exitCode[0]; + } + + private static String collectPodLogs(KubernetesClient client, + String podName, String namespace) { + try { + return client.pods().inNamespace(namespace).withName(podName) + .inContainer("test").getLog(); + } catch (Exception e) { + return "Failed to retrieve logs: " + e.getMessage(); + } + } + + private static String collectServerPodLogs(KubernetesClient client, String namespace) { + final StringBuilder sb = new StringBuilder(); + try { + client.pods().inNamespace(namespace) + .withLabel("app") + .list().getItems() + .forEach(pod -> { + final String pName = pod.getMetadata().getName(); + pod.getSpec().getContainers().forEach(c -> { + try { + final String podLogs = client.pods().inNamespace(namespace) + .withName(pName) + .inContainer(c.getName()).getLog(); + sb.append("\n=== Pod '").append(pName) + .append("' container '").append(c.getName()).append("' ===\n") + .append(podLogs); + } catch (Exception e) { + sb.append("\n[Failed to get logs for pod '").append(pName) + .append("' container '").append(c.getName()).append("': ") + .append(e.getMessage()).append(']'); + } + }); + }); + } catch (Exception e) { + sb.append("[Failed to list server pods: ").append(e.getMessage()).append(']'); + } + return sb.toString(); + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestImage.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestImage.java new file mode 100644 index 00000000000..5ec74c4a4cc --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/IstioTestImage.java @@ -0,0 +1,94 @@ +/* + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.Container.ExecResult; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.k3s.K3sContainer; +import org.testcontainers.utility.MountableFile; + +final class IstioTestImage { + + private static final Logger logger = LoggerFactory.getLogger(IstioTestImage.class); + + static final String IMAGE_NAME = "armeria-istio-test:latest"; + + static ImageFromDockerfile build() { + final Path runtimeDir = IstioEnv.testRuntimeDir(); + return new ImageFromDockerfile(IMAGE_NAME, false) + .withDockerfileFromBuilder(builder -> builder + .from("eclipse-temurin:21-jre") + .copy("app/", "/app/") + .entryPoint("java", "-cp", "/app:/app/*", + "com.linecorp.armeria.it.istio.testing.IstioPodEntryPoint") + .build()) + .withFileFromPath("app/", runtimeDir); + } + + /** + * Loads {@link #IMAGE_NAME} into the containerd store of a K3s container so that pods + * scheduled with {@code imagePullPolicy: Never} can find it. + * + *

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 nodes = client.nodes().list().getItems(); + return !nodes.isEmpty() && nodes.stream().allMatch(K8sClusterHelper::isReady); + } + + private static boolean isReady(Node node) { + if (node.getStatus() == null || node.getStatus().getConditions() == null) { + return false; + } + return node.getStatus().getConditions().stream() + .anyMatch(K8sClusterHelper::isReadyCondition); + } + + private static boolean isReadyCondition(NodeCondition condition) { + return "Ready".equals(condition.getType()) && "True".equals(condition.getStatus()); + } + + private static void sleep(Duration interval) { + final long millis = interval.toMillis(); + try { + Thread.sleep(millis > 0 ? millis : 1L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/PodCustomizer.java b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/PodCustomizer.java new file mode 100644 index 00000000000..4b18149c275 --- /dev/null +++ b/it/xds-istio/src/main/java/com/linecorp/armeria/it/istio/testing/PodCustomizer.java @@ -0,0 +1,45 @@ +/* + * 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 io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.api.model.PodBuilder; + +/** + * Pluggable hook that applies pod-level customizations (annotations, volume mounts, etc.) + * before a test pod is submitted to Kubernetes. Implement this interface to inject + * sidecar-specific or environment-specific configuration without modifying + * {@link IstioTestExtension} directly. + * + *

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 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("Istio bootstrap file ('{}'):\n{}", name, + Files.readString(dir.resolve(name))); + } + } + } +} diff --git a/it/xds-istio/src/test/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration b/it/xds-istio/src/test/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration new file mode 100644 index 00000000000..6118222c0dc --- /dev/null +++ b/it/xds-istio/src/test/resources/META-INF/services/reactor.blockhound.integration.BlockHoundIntegration @@ -0,0 +1 @@ +com.linecorp.armeria.it.istio.testing.IstioTestingBlockHoundIntegration diff --git a/settings.gradle b/settings.gradle index dbf78b8e8bc..f74a0995656 100644 --- a/settings.gradle +++ b/settings.gradle @@ -261,6 +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-no-validation', 'java17' includeWithFlags ':jetty9.3', 'java', 'relocate' project(':jetty9.3').projectDir = file('jetty/jetty9.3')