From a332eb2887aae3bc915ada8bf5442f81e0551a78 Mon Sep 17 00:00:00 2001 From: "pg.yang" Date: Wed, 9 Aug 2023 23:03:03 +0800 Subject: [PATCH] Convert kubernetes-client-7.0 unit tests to Java (#9163) --- .../groovy/KubernetesRequestUtilsTest.groovy | 87 ------ .../KubernetesRequestUtilsTest.java | 192 ++++++++++++ .../test/groovy/KubernetesClientTest.groovy | 222 ------------- .../KubernetesClientTest.java | 291 ++++++++++++++++++ 4 files changed, 483 insertions(+), 309 deletions(-) delete mode 100644 instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/groovy/KubernetesRequestUtilsTest.groovy create mode 100644 instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesRequestUtilsTest.java delete mode 100644 instrumentation/kubernetes-client-7.0/javaagent/src/test/groovy/KubernetesClientTest.groovy create mode 100644 instrumentation/kubernetes-client-7.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesClientTest.java diff --git a/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/groovy/KubernetesRequestUtilsTest.groovy b/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/groovy/KubernetesRequestUtilsTest.groovy deleted file mode 100644 index 19b7f6b5b0..0000000000 --- a/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/groovy/KubernetesRequestUtilsTest.groovy +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import io.opentelemetry.javaagent.instrumentation.kubernetesclient.KubernetesRequestDigest -import io.opentelemetry.javaagent.instrumentation.kubernetesclient.KubernetesResource -import io.opentelemetry.javaagent.instrumentation.kubernetesclient.KubernetesVerb -import spock.lang.Specification - -class KubernetesRequestUtilsTest extends Specification { - def "asserting non-resource requests should work"() { - expect: - !KubernetesRequestDigest.isResourceRequest("/api") - !KubernetesRequestDigest.isResourceRequest("/apis") - !KubernetesRequestDigest.isResourceRequest("/apis/v1") - !KubernetesRequestDigest.isResourceRequest("/healthz") - !KubernetesRequestDigest.isResourceRequest("/swagger.json") - !KubernetesRequestDigest.isResourceRequest("/api/v1") - !KubernetesRequestDigest.isResourceRequest("/api/v1/") - !KubernetesRequestDigest.isResourceRequest("/apis/apps/v1") - !KubernetesRequestDigest.isResourceRequest("/apis/apps/v1/") - } - - def "asserting resource requests should work"() { - expect: - KubernetesRequestDigest.isResourceRequest("/apis/example.io/v1/foos") - KubernetesRequestDigest.isResourceRequest("/apis/example.io/v1/namespaces/default/foos") - KubernetesRequestDigest.isResourceRequest("/api/v1/namespaces") - KubernetesRequestDigest.isResourceRequest("/api/v1/pods") - KubernetesRequestDigest.isResourceRequest("/api/v1/namespaces/default/pods") - } - - def "parsing core resource from url-path should work"(String urlPath, String apiGroup, String apiVersion, String resource, String subResource, String namespace, String name) { - expect: - KubernetesResource.parseCoreResource(urlPath).apiGroup == apiGroup - KubernetesResource.parseCoreResource(urlPath).apiVersion == apiVersion - KubernetesResource.parseCoreResource(urlPath).resource == resource - KubernetesResource.parseCoreResource(urlPath).subResource == subResource - KubernetesResource.parseCoreResource(urlPath).namespace == namespace - KubernetesResource.parseCoreResource(urlPath).name == name - - where: - urlPath | apiGroup | apiVersion | resource | subResource | namespace | name - "/api/v1/pods" | "" | "v1" | "pods" | null | null | null - "/api/v1/namespaces/default/pods" | "" | "v1" | "pods" | null | "default" | null - "/api/v1/namespaces/default/pods/foo" | "" | "v1" | "pods" | null | "default" | "foo" - "/api/v1/namespaces/default/pods/foo/exec" | "" | "v1" | "pods" | "exec" | "default" | "foo" - } - - def "parsing regular non-core resource from url-path should work"(String urlPath, String apiGroup, String apiVersion, String resource, String subResource, String namespace, String name) { - expect: - KubernetesResource.parseRegularResource(urlPath).apiGroup == apiGroup - KubernetesResource.parseRegularResource(urlPath).apiVersion == apiVersion - KubernetesResource.parseRegularResource(urlPath).resource == resource - KubernetesResource.parseRegularResource(urlPath).subResource == subResource - KubernetesResource.parseRegularResource(urlPath).namespace == namespace - KubernetesResource.parseRegularResource(urlPath).name == name - - where: - urlPath | apiGroup | apiVersion | resource | subResource | namespace | name - "/apis/apps/v1/deployments" | "apps" | "v1" | "deployments" | null | null | null - "/apis/apps/v1/namespaces/default/deployments" | "apps" | "v1" | "deployments" | null | "default" | null - "/apis/apps/v1/namespaces/default/deployments/foo" | "apps" | "v1" | "deployments" | null | "default" | "foo" - "/apis/apps/v1/namespaces/default/deployments/foo/status" | "apps" | "v1" | "deployments" | "status" | "default" | "foo" - "/apis/example.io/v1alpha1/foos" | "example.io" | "v1alpha1" | "foos" | null | null | null - "/apis/example.io/v1alpha1/namespaces/default/foos" | "example.io" | "v1alpha1" | "foos" | null | "default" | null - "/apis/example.io/v1alpha1/namespaces/default/foos/foo" | "example.io" | "v1alpha1" | "foos" | null | "default" | "foo" - "/apis/example.io/v1alpha1/namespaces/default/foos/foo/status" | "example.io" | "v1alpha1" | "foos" | "status" | "default" | "foo" - } - - def "parsing kubernetes request verbs should work"(String httpVerb, boolean hasNamePathParam, boolean hasWatchParam, KubernetesVerb kubernetesVerb) { - expect: - KubernetesVerb.of(httpVerb, hasNamePathParam, hasWatchParam) == kubernetesVerb - - where: - httpVerb | hasNamePathParam | hasWatchParam | kubernetesVerb - "GET" | true | false | KubernetesVerb.GET - "GET" | false | true | KubernetesVerb.WATCH - "GET" | false | false | KubernetesVerb.LIST - "POST" | false | false | KubernetesVerb.CREATE - "PUT" | false | false | KubernetesVerb.UPDATE - "PATCH" | false | false | KubernetesVerb.PATCH - "DELETE" | true | false | KubernetesVerb.DELETE - "DELETE" | false | false | KubernetesVerb.DELETE_COLLECTION - } -} diff --git a/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesRequestUtilsTest.java b/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesRequestUtilsTest.java new file mode 100644 index 0000000000..f50285b5a7 --- /dev/null +++ b/instrumentation/kubernetes-client-7.0/javaagent-unit-tests/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesRequestUtilsTest.java @@ -0,0 +1,192 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.kubernetesclient; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; + +class KubernetesRequestUtilsTest { + + @Test + void isResourceRequest() { + assertThat(KubernetesRequestDigest.isResourceRequest("/api")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/apis")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/apis/v1")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/healthz")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/swagger.json")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/api/v1")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/api/v1/")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/apis/apps/v1")).isFalse(); + assertThat(KubernetesRequestDigest.isResourceRequest("/apis/apps/v1/")).isFalse(); + + assertThat(KubernetesRequestDigest.isResourceRequest("/apis/example.io/v1/foos")).isTrue(); + assertThat( + KubernetesRequestDigest.isResourceRequest( + "/apis/example.io/v1/namespaces/default/foos")) + .isTrue(); + assertThat(KubernetesRequestDigest.isResourceRequest("/api/v1/namespaces")).isTrue(); + assertThat(KubernetesRequestDigest.isResourceRequest("/api/v1/pods")).isTrue(); + assertThat(KubernetesRequestDigest.isResourceRequest("/api/v1/namespaces/default/pods")) + .isTrue(); + } + + @ParameterizedTest + @ArgumentsSource(ParseCoreResourceArgumentsProvider.class) + void parseCoreResource( + String urlPath, + String apiGroup, + String apiVersion, + String resource, + String subResource, + String namespace, + String name) + throws ParseKubernetesResourceException { + assertThat(KubernetesResource.parseCoreResource(urlPath).getApiGroup()).isEqualTo(apiGroup); + assertThat(KubernetesResource.parseCoreResource(urlPath).getApiVersion()).isEqualTo(apiVersion); + assertThat(KubernetesResource.parseCoreResource(urlPath).getResource()).isEqualTo(resource); + assertThat(KubernetesResource.parseCoreResource(urlPath).getSubResource()) + .isEqualTo(subResource); + assertThat(KubernetesResource.parseCoreResource(urlPath).getNamespace()).isEqualTo(namespace); + assertThat(KubernetesResource.parseCoreResource(urlPath).getName()).isEqualTo(name); + } + + @ParameterizedTest + @ArgumentsSource(ParseRegularResourceArgumentsProvider.class) + void parseRegularResource( + String urlPath, + String apiGroup, + String apiVersion, + String resource, + String subResource, + String namespace, + String name) + throws ParseKubernetesResourceException { + assertThat(KubernetesResource.parseRegularResource(urlPath).getApiGroup()).isEqualTo(apiGroup); + assertThat(KubernetesResource.parseRegularResource(urlPath).getApiVersion()) + .isEqualTo(apiVersion); + assertThat(KubernetesResource.parseRegularResource(urlPath).getResource()).isEqualTo(resource); + assertThat(KubernetesResource.parseRegularResource(urlPath).getSubResource()) + .isEqualTo(subResource); + assertThat(KubernetesResource.parseRegularResource(urlPath).getNamespace()) + .isEqualTo(namespace); + assertThat(KubernetesResource.parseRegularResource(urlPath).getName()).isEqualTo(name); + } + + @ParameterizedTest + @ArgumentsSource(K8sRequestVerbsArgumentsProvider.class) + void k8sRequestVerbs( + String httpVerb, + boolean hasNamePathParam, + boolean hasWatchParam, + KubernetesVerb kubernetesVerb) { + assertThat(KubernetesVerb.of(httpVerb, hasNamePathParam, hasWatchParam)) + .isEqualTo(kubernetesVerb); + } + + private static class K8sRequestVerbsArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext extensionContext) + throws Exception { + return Stream.of( + Arguments.of("GET", true, false, KubernetesVerb.GET), + Arguments.of("GET", false, true, KubernetesVerb.WATCH), + Arguments.of("GET", false, false, KubernetesVerb.LIST), + Arguments.of("POST", false, false, KubernetesVerb.CREATE), + Arguments.of("PUT", false, false, KubernetesVerb.UPDATE), + Arguments.of("PATCH", false, false, KubernetesVerb.PATCH), + Arguments.of("DELETE", true, false, KubernetesVerb.DELETE), + Arguments.of("DELETE", false, false, KubernetesVerb.DELETE_COLLECTION)); + } + } + + private static class ParseRegularResourceArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext extensionContext) + throws Exception { + return Stream.of( + Arguments.of("/apis/apps/v1/deployments", "apps", "v1", "deployments", null, null, null), + Arguments.of( + "/apis/apps/v1/namespaces/default/deployments", + "apps", + "v1", + "deployments", + null, + "default", + null), + Arguments.of( + "/apis/apps/v1/namespaces/default/deployments/foo", + "apps", + "v1", + "deployments", + null, + "default", + "foo"), + Arguments.of( + "/apis/apps/v1/namespaces/default/deployments/foo/status", + "apps", + "v1", + "deployments", + "status", + "default", + "foo"), + Arguments.of( + "/apis/example.io/v1alpha1/foos", "example.io", "v1alpha1", "foos", null, null, null), + Arguments.of( + "/apis/example.io/v1alpha1/namespaces/default/foos", + "example.io", + "v1alpha1", + "foos", + null, + "default", + null), + Arguments.of( + "/apis/example.io/v1alpha1/namespaces/default/foos/foo", + "example.io", + "v1alpha1", + "foos", + null, + "default", + "foo"), + Arguments.of( + "/apis/example.io/v1alpha1/namespaces/default/foos/foo/status", + "example.io", + "v1alpha1", + "foos", + "status", + "default", + "foo")); + } + } + + private static class ParseCoreResourceArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ExtensionContext context) { + return Stream.of( + Arguments.of("/api/v1/pods", "", "v1", "pods", null, null, null), + Arguments.of("/api/v1/namespaces/default/pods", "", "v1", "pods", null, "default", null), + Arguments.of( + "/api/v1/namespaces/default/pods/foo", "", "v1", "pods", null, "default", "foo"), + Arguments.of( + "/api/v1/namespaces/default/pods/foo/exec", + "", + "v1", + "pods", + "exec", + "default", + "foo")); + } + } +} diff --git a/instrumentation/kubernetes-client-7.0/javaagent/src/test/groovy/KubernetesClientTest.groovy b/instrumentation/kubernetes-client-7.0/javaagent/src/test/groovy/KubernetesClientTest.groovy deleted file mode 100644 index f7becdd2dc..0000000000 --- a/instrumentation/kubernetes-client-7.0/javaagent/src/test/groovy/KubernetesClientTest.groovy +++ /dev/null @@ -1,222 +0,0 @@ -/* - * Copyright The OpenTelemetry Authors - * SPDX-License-Identifier: Apache-2.0 - */ - -import io.kubernetes.client.openapi.ApiCallback -import io.kubernetes.client.openapi.ApiClient -import io.kubernetes.client.openapi.ApiException -import io.kubernetes.client.openapi.apis.CoreV1Api -import io.opentelemetry.api.trace.SpanKind -import io.opentelemetry.instrumentation.test.AgentInstrumentationSpecification -import io.opentelemetry.instrumentation.test.asserts.TraceAssert -import io.opentelemetry.semconv.trace.attributes.SemanticAttributes -import io.opentelemetry.testing.internal.armeria.common.HttpResponse -import io.opentelemetry.testing.internal.armeria.common.HttpStatus -import io.opentelemetry.testing.internal.armeria.common.MediaType -import io.opentelemetry.testing.internal.armeria.testing.junit5.server.mock.MockWebServerExtension -import spock.lang.Shared - -import java.util.concurrent.CountDownLatch -import java.util.concurrent.atomic.AtomicReference - -import static io.opentelemetry.api.trace.SpanKind.CLIENT -import static io.opentelemetry.api.trace.StatusCode.ERROR - -class KubernetesClientTest extends AgentInstrumentationSpecification { - private static final String TEST_USER_AGENT = "test-user-agent" - - @Shared - def server = new MockWebServerExtension() - - @Shared - CoreV1Api api - - def setupSpec() { - server.start() - def apiClient = new ApiClient() - apiClient.setUserAgent(TEST_USER_AGENT) - apiClient.basePath = server.httpUri().toString() - api = new CoreV1Api(apiClient) - } - - def cleanupSpec() { - server.stop() - } - - def setup() { - server.beforeTestExecution(null) - } - - def "Kubernetes span is registered on a synchronous call"() { - given: - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "42")) - - when: - def response = runWithSpan("parent") { - api.connectGetNamespacedPodProxy("name", "namespace", "path") - } - - then: - response == "42" - server.takeRequest().request().headers().get("traceparent") != null - - assertTraces(1) { - trace(0, 2) { - span(0) { - name "parent" - kind SpanKind.INTERNAL - hasNoParent() - } - apiClientSpan(it, 1, "get pods/proxy", "${server.httpUri()}/api/v1/namespaces/namespace/pods/name/proxy?path=path", 200) - } - } - } - - def "Kubernetes instrumentation handles errors on a synchronous call"() { - given: - server.enqueue(HttpResponse.of(HttpStatus.valueOf(451), MediaType.PLAIN_TEXT_UTF_8, "42")) - - when: - runWithSpan("parent") { - api.connectGetNamespacedPodProxy("name", "namespace", "path") - } - - then: - def exception = thrown(ApiException) - server.takeRequest().request().headers().get("traceparent") != null - - assertTraces(1) { - trace(0, 2) { - span(0) { - name "parent" - kind SpanKind.INTERNAL - hasNoParent() - status ERROR - errorEvent(exception.class, exception.message) - } - apiClientSpan(it, 1, "get pods/proxy", "${server.httpUri()}/api/v1/namespaces/namespace/pods/name/proxy?path=path", 451, exception) - } - } - } - - def "Kubernetes span is registered on an asynchronous call"() { - given: - server.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "42")) - - when: - def responseBody = new AtomicReference() - def latch = new CountDownLatch(1) - - runWithSpan("parent") { - api.connectGetNamespacedPodProxyAsync("name", "namespace", "path", new ApiCallbackTemplate() { - @Override - void onSuccess(String result, int statusCode, Map> responseHeaders) { - responseBody.set(result) - latch.countDown() - runWithSpan("callback") {} - } - }) - } - - then: - latch.await() - responseBody.get() == "42" - server.takeRequest().request().headers().get("traceparent") != null - - assertTraces(1) { - trace(0, 3) { - span(0) { - name "parent" - kind SpanKind.INTERNAL - hasNoParent() - } - apiClientSpan(it, 1, "get pods/proxy", "${server.httpUri()}/api/v1/namespaces/namespace/pods/name/proxy?path=path", 200) - span(2) { - name "callback" - kind SpanKind.INTERNAL - childOf span(0) - } - } - } - } - - def "Kubernetes instrumentation handles errors on an asynchronous call"() { - given: - server.enqueue(HttpResponse.of(HttpStatus.valueOf(451), MediaType.PLAIN_TEXT_UTF_8, "42")) - - when: - def exception = new AtomicReference() - def latch = new CountDownLatch(1) - - runWithSpan("parent") { - api.connectGetNamespacedPodProxyAsync("name", "namespace", "path", new ApiCallbackTemplate() { - @Override - void onFailure(ApiException e, int statusCode, Map> responseHeaders) { - exception.set(e) - latch.countDown() - runWithSpan("callback") {} - } - }) - } - - then: - latch.await() - exception.get() != null - server.takeRequest().request().headers().get("traceparent") != null - - assertTraces(1) { - trace(0, 3) { - span(0) { - name "parent" - kind SpanKind.INTERNAL - hasNoParent() - } - apiClientSpan(it, 1, "get pods/proxy", "${server.httpUri()}/api/v1/namespaces/namespace/pods/name/proxy?path=path", 451, exception.get()) - span(2) { - name "callback" - kind SpanKind.INTERNAL - childOf span(0) - } - } - } - } - - private void apiClientSpan(TraceAssert trace, int index, String spanName, String url, int statusCode, Throwable exception = null) { - boolean hasFailed = exception != null - trace.span(index) { - name spanName - kind CLIENT - childOf trace.span(0) - if (hasFailed) { - status ERROR - errorEvent exception.class, exception.message - } - attributes { - "$SemanticAttributes.HTTP_URL" url - "$SemanticAttributes.HTTP_METHOD" "GET" - "$SemanticAttributes.USER_AGENT_ORIGINAL" TEST_USER_AGENT - "$SemanticAttributes.HTTP_STATUS_CODE" statusCode - "$SemanticAttributes.NET_PEER_NAME" "127.0.0.1" - "$SemanticAttributes.NET_PEER_PORT" server.httpPort() - "$SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH" Long - "kubernetes-client.namespace" "namespace" - "kubernetes-client.name" "name" - } - } - } - - static class ApiCallbackTemplate implements ApiCallback { - @Override - void onFailure(ApiException e, int statusCode, Map> responseHeaders) {} - - @Override - void onSuccess(String result, int statusCode, Map> responseHeaders) {} - - @Override - void onUploadProgress(long bytesWritten, long contentLength, boolean done) {} - - @Override - void onDownloadProgress(long bytesRead, long contentLength, boolean done) {} - } -} diff --git a/instrumentation/kubernetes-client-7.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesClientTest.java b/instrumentation/kubernetes-client-7.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesClientTest.java new file mode 100644 index 0000000000..75917509f4 --- /dev/null +++ b/instrumentation/kubernetes-client-7.0/javaagent/src/test/java/io/opentelemetry/javaagent/instrumentation/kubernetesclient/KubernetesClientTest.java @@ -0,0 +1,291 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +package io.opentelemetry.javaagent.instrumentation.kubernetesclient; + +import static io.opentelemetry.instrumentation.testing.util.TelemetryDataUtil.orderByRootSpanName; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.equalTo; +import static io.opentelemetry.sdk.testing.assertj.OpenTelemetryAssertions.satisfies; +import static org.assertj.core.api.Assertions.assertThat; + +import io.kubernetes.client.openapi.ApiCallback; +import io.kubernetes.client.openapi.ApiClient; +import io.kubernetes.client.openapi.ApiException; +import io.kubernetes.client.openapi.apis.CoreV1Api; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.trace.SpanKind; +import io.opentelemetry.instrumentation.testing.junit.AgentInstrumentationExtension; +import io.opentelemetry.instrumentation.testing.junit.InstrumentationExtension; +import io.opentelemetry.sdk.trace.data.StatusData; +import io.opentelemetry.semconv.trace.attributes.SemanticAttributes; +import io.opentelemetry.testing.internal.armeria.common.HttpResponse; +import io.opentelemetry.testing.internal.armeria.common.HttpStatus; +import io.opentelemetry.testing.internal.armeria.common.MediaType; +import io.opentelemetry.testing.internal.armeria.testing.junit5.server.mock.MockWebServerExtension; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; +import org.assertj.core.api.AbstractAssert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class KubernetesClientTest { + + private static final String TEST_USER_AGENT = "test-user-agent"; + + @RegisterExtension + private static final InstrumentationExtension testing = AgentInstrumentationExtension.create(); + + private final MockWebServerExtension mockWebServer = new MockWebServerExtension(); + + private CoreV1Api coreV1Api; + + @BeforeEach + void beforeEach() { + mockWebServer.start(); + ApiClient apiClient = new ApiClient(); + apiClient.setUserAgent(TEST_USER_AGENT); + apiClient.setBasePath(mockWebServer.httpUri().toString()); + coreV1Api = new CoreV1Api(apiClient); + } + + @AfterEach + void afterEach() { + mockWebServer.stop(); + } + + @Test + void synchronousCall() throws ApiException { + mockWebServer.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "42")); + String response = + testing.runWithSpan( + "parent", () -> coreV1Api.connectGetNamespacedPodProxy("name", "namespace", "path")); + + assertThat(response).isEqualTo("42"); + assertThat(mockWebServer.takeRequest().request().headers().get("traceparent")).isNotBlank(); + + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), + span -> + span.hasName("get pods/proxy") + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + equalTo( + SemanticAttributes.HTTP_URL, + mockWebServer.httpUri() + + "/api/v1/namespaces/namespace/pods/name/proxy?path=path"), + equalTo(SemanticAttributes.HTTP_METHOD, "GET"), + equalTo(SemanticAttributes.USER_AGENT_ORIGINAL, TEST_USER_AGENT), + equalTo(SemanticAttributes.HTTP_STATUS_CODE, 200), + equalTo(SemanticAttributes.NET_PEER_NAME, "127.0.0.1"), + equalTo(SemanticAttributes.NET_PEER_PORT, mockWebServer.httpPort()), + satisfies( + SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH, + AbstractAssert::isNotNull), + equalTo( + AttributeKey.stringKey("kubernetes-client.namespace"), "namespace"), + equalTo(AttributeKey.stringKey("kubernetes-client.name"), "name")))); + } + + @Test + void handleErrorsInSyncCall() { + mockWebServer.enqueue( + HttpResponse.of(HttpStatus.valueOf(451), MediaType.PLAIN_TEXT_UTF_8, "42")); + AtomicReference apiExceptionReference = new AtomicReference<>(null); + try { + testing.runWithSpan( + "parent", + () -> { + coreV1Api.connectGetNamespacedPodProxy("name", "namespace", "path"); + }); + } catch (ApiException e) { + apiExceptionReference.set(e); + } + assertThat(apiExceptionReference.get()).isNotNull(); + assertThat(mockWebServer.takeRequest().request().headers().get("traceparent")).isNotBlank(); + + ApiException apiException = apiExceptionReference.get(); + + testing.waitAndAssertTraces( + trace -> + trace.hasSpansSatisfyingExactly( + span -> + span.hasName("parent") + .hasKind(SpanKind.INTERNAL) + .hasNoParent() + .hasStatus(StatusData.error()) + .hasException(apiException), + span -> + span.hasName("get pods/proxy") + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasStatus(StatusData.error()) + .hasException(apiException) + .hasAttributesSatisfyingExactly( + equalTo( + SemanticAttributes.HTTP_URL, + mockWebServer.httpUri() + + "/api/v1/namespaces/namespace/pods/name/proxy?path=path"), + equalTo(SemanticAttributes.HTTP_METHOD, "GET"), + equalTo(SemanticAttributes.USER_AGENT_ORIGINAL, TEST_USER_AGENT), + equalTo(SemanticAttributes.HTTP_STATUS_CODE, 451), + equalTo(SemanticAttributes.NET_PEER_NAME, "127.0.0.1"), + equalTo(SemanticAttributes.NET_PEER_PORT, mockWebServer.httpPort()), + satisfies( + SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH, + AbstractAssert::isNotNull), + equalTo( + AttributeKey.stringKey("kubernetes-client.namespace"), "namespace"), + equalTo(AttributeKey.stringKey("kubernetes-client.name"), "name")))); + } + + @Test + void asynchronousCall() throws ApiException, InterruptedException { + mockWebServer.enqueue(HttpResponse.of(HttpStatus.OK, MediaType.PLAIN_TEXT_UTF_8, "42")); + + AtomicReference responseBodyReference = new AtomicReference<>(); + CountDownLatch countDownLatch = new CountDownLatch(1); + + testing.runWithSpan( + "parent", + () -> { + coreV1Api.connectGetNamespacedPodProxyAsync( + "name", + "namespace", + "path", + new ApiCallbackTemplate() { + @Override + public void onSuccess( + String result, int statusCode, Map> responseHeaders) { + responseBodyReference.set(result); + countDownLatch.countDown(); + testing.runWithSpan("callback", () -> {}); + } + }); + }); + + countDownLatch.await(); + + assertThat(responseBodyReference.get()).isEqualTo("42"); + assertThat(mockWebServer.takeRequest().request().headers().get("traceparent")).isNotBlank(); + + testing.waitAndAssertSortedTraces( + orderByRootSpanName("parent", "get pods/proxy", "callback"), + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), + span -> + span.hasName("get pods/proxy") + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasAttributesSatisfyingExactly( + equalTo( + SemanticAttributes.HTTP_URL, + mockWebServer.httpUri() + + "/api/v1/namespaces/namespace/pods/name/proxy?path=path"), + equalTo(SemanticAttributes.HTTP_METHOD, "GET"), + equalTo(SemanticAttributes.USER_AGENT_ORIGINAL, TEST_USER_AGENT), + equalTo(SemanticAttributes.HTTP_STATUS_CODE, 200), + equalTo(SemanticAttributes.NET_PEER_NAME, "127.0.0.1"), + equalTo(SemanticAttributes.NET_PEER_PORT, mockWebServer.httpPort()), + satisfies( + SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH, + AbstractAssert::isNotNull), + equalTo( + AttributeKey.stringKey("kubernetes-client.namespace"), "namespace"), + equalTo(AttributeKey.stringKey("kubernetes-client.name"), "name")), + span -> + span.hasName("callback") + .hasKind(SpanKind.INTERNAL) + .hasParent(trace.getSpan(0)))); + } + + @Test + void handleErrorsInAsynchronousCall() throws ApiException, InterruptedException { + + mockWebServer.enqueue( + HttpResponse.of(HttpStatus.valueOf(451), MediaType.PLAIN_TEXT_UTF_8, "42")); + + AtomicReference exceptionReference = new AtomicReference<>(); + CountDownLatch countDownLatch = new CountDownLatch(1); + + testing.runWithSpan( + "parent", + () -> { + coreV1Api.connectGetNamespacedPodProxyAsync( + "name", + "namespace", + "path", + new ApiCallbackTemplate() { + @Override + public void onFailure( + ApiException e, int statusCode, Map> responseHeaders) { + exceptionReference.set(e); + countDownLatch.countDown(); + testing.runWithSpan("callback", () -> {}); + } + }); + }); + + countDownLatch.await(); + + assertThat(exceptionReference.get()).isNotNull(); + assertThat(mockWebServer.takeRequest().request().headers().get("traceparent")).isNotBlank(); + + testing.waitAndAssertSortedTraces( + orderByRootSpanName("parent", "get pods/proxy", "callback"), + trace -> + trace.hasSpansSatisfyingExactly( + span -> span.hasName("parent").hasKind(SpanKind.INTERNAL).hasNoParent(), + span -> + span.hasName("get pods/proxy") + .hasKind(SpanKind.CLIENT) + .hasParent(trace.getSpan(0)) + .hasStatus(StatusData.error()) + .hasException(exceptionReference.get()) + .hasAttributesSatisfyingExactly( + equalTo( + SemanticAttributes.HTTP_URL, + mockWebServer.httpUri() + + "/api/v1/namespaces/namespace/pods/name/proxy?path=path"), + equalTo(SemanticAttributes.HTTP_METHOD, "GET"), + equalTo(SemanticAttributes.USER_AGENT_ORIGINAL, TEST_USER_AGENT), + equalTo(SemanticAttributes.HTTP_STATUS_CODE, 451), + equalTo(SemanticAttributes.NET_PEER_NAME, "127.0.0.1"), + equalTo(SemanticAttributes.NET_PEER_PORT, mockWebServer.httpPort()), + satisfies( + SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH, + AbstractAssert::isNotNull), + equalTo( + AttributeKey.stringKey("kubernetes-client.namespace"), "namespace"), + equalTo(AttributeKey.stringKey("kubernetes-client.name"), "name")), + span -> + span.hasName("callback") + .hasKind(SpanKind.INTERNAL) + .hasParent(trace.getSpan(0)))); + } + + private static class ApiCallbackTemplate implements ApiCallback { + @Override + public void onFailure( + ApiException e, int statusCode, Map> responseHeaders) {} + + @Override + public void onSuccess( + String result, int statusCode, Map> responseHeaders) {} + + @Override + public void onUploadProgress(long bytesWritten, long contentLength, boolean done) {} + + @Override + public void onDownloadProgress(long bytesRead, long contentLength, boolean done) {} + } +}