diff --git a/build.gradle b/build.gradle index b571e1df..76c69e9e 100644 --- a/build.gradle +++ b/build.gradle @@ -227,6 +227,7 @@ subprojects { mockito : "org.mockito:mockito-inline:${mockitoVersion}", mockito_jupiter: "org.mockito:mockito-junit-jupiter:${mockitoVersion}", slf4j_simple: "org.slf4j:slf4j-simple:${slf4jVersion}", + spring_boot_starter_test: "org.springframework.boot:spring-boot-starter-test:${springVersion}", opentelemetry_sdk_testing: "io.opentelemetry:opentelemetry-sdk-testing:${openTelemetryVersion}", test_containers: "org.testcontainers:testcontainers:${testContainersVersion}", wiremock : "com.github.tomakehurst:wiremock-jre8:${wiremockVersion}", diff --git a/javaagent-extensions/gcp-auth/build.gradle b/javaagent-extensions/gcp-auth/build.gradle index 248c3702..ee555fb5 100644 --- a/javaagent-extensions/gcp-auth/build.gradle +++ b/javaagent-extensions/gcp-auth/build.gradle @@ -1,3 +1,20 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 + * + * http://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. + */ +import org.springframework.boot.gradle.tasks.run.BootRun + /* * Copyright 2024 Google LLC * @@ -17,6 +34,7 @@ plugins { id 'java' id 'java-library' id 'com.github.johnrengelman.shadow' + id 'org.springframework.boot' version '3.4.1' } description = 'OpenTelemetry Java Agent Extension that enables authentication support for OTLP exporters' @@ -62,6 +80,12 @@ dependencies { testImplementation(testLibraries.mockito) testImplementation(testLibraries.mockito_jupiter) testImplementation(libraries.opentelemetry_sdk_autoconf) + // for implementing smoke test application + testImplementation(libraries.spring_boot_starter_web) + testImplementation(testLibraries.spring_boot_starter_test) + testImplementation("org.mock-server:mockserver-netty:5.15.0") + testImplementation("io.opentelemetry.proto:opentelemetry-proto:1.4.0-alpha") + testImplementation("io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:2.8.0") // OTel instrumentation used in the sample app to facilitate integration testing agent agentLibraries.agent testImplementation 'org.awaitility:awaitility:4.2.2' @@ -70,7 +94,7 @@ dependencies { } // task to copy and rename the Java Auto-Instrumentation Agent into 'libs' folder -tasks.register('CopyAgent', Copy) { +tasks.register('copyAgent', Copy) { into layout.buildDirectory.dir("libs") from configurations.agent { rename "opentelemetry-javaagent(.*).jar", "otel-agent.jar" @@ -78,7 +102,7 @@ tasks.register('CopyAgent', Copy) { } tasks.register('BuildTestApp', org.gradle.jvm.tasks.Jar) { - dependsOn 'CopyAgent' + dependsOn 'copyAgent' dependsOn 'shadowJar' archiveFileName.set("auto-instrumented-test-server.jar") @@ -96,7 +120,41 @@ tasks.register('BuildTestApp', org.gradle.jvm.tasks.Jar) { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } +def builtLibsDir = layout.buildDirectory.dir("libs").get().toString() +def javaAgentJarPath = builtLibsDir + "/otel-agent.jar" +def authExtensionJarPath = builtLibsDir + "/gcp-auth-extension.jar" + +// this task is run as part of the integration test so it is necessary to +// configure this +tasks.named('bootJar').configure { + dependsOn('copyAgent') +} + +build { + // disable bootJar in build since it only runs as part of test + tasks.named('bootJar').configure { + enabled = false + } +} + test { - dependsOn 'BuildTestApp' + dependsOn 'shadowJar' + dependsOn 'copyAgent' useJUnitPlatform() + + environment("GOOGLE_CLOUD_QUOTA_PROJECT", "test-project-id") + jvmArgs = [ + "-javaagent:${javaAgentJarPath}", + "-Dotel.javaagent.extensions=${authExtensionJarPath}", + "-Dgoogle.cloud.project=my-gcp-project", + "-Dotel.java.global-autoconfigure.enabled=true", + "-Dotel.exporter.otlp.endpoint=http://localhost:4318", + "-Dotel.resource.providers.gcp.enabled=true", + "-Dotel.traces.exporter=otlp", + "-Dotel.bsp.schedule.delay=2000", + "-Dotel.metrics.exporter=none", + "-Dotel.logs.exporter=none", + "-Dotel.exporter.otlp.protocol=http/protobuf", + "-Dmockserver.logLevel=off" + ] } diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionSmokeTest.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionSmokeTest.java new file mode 100644 index 00000000..86243dc4 --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/GcpAuthExtensionSmokeTest.java @@ -0,0 +1,190 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 + * + * http://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.google.cloud.opentelemetry.extension.auth; + +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.GCP_USER_PROJECT_ID_KEY; +import static com.google.cloud.opentelemetry.extension.auth.GcpAuthAutoConfigurationCustomizerProvider.QUOTA_USER_PROJECT_HEADER; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.stop.Stop.stopQuietly; + +import com.google.cloud.opentelemetry.extension.auth.springapp.Application; +import com.google.protobuf.InvalidProtocolBufferException; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import io.opentelemetry.proto.common.v1.AnyValue; +import io.opentelemetry.proto.common.v1.KeyValue; +import io.opentelemetry.proto.trace.v1.ResourceSpans; +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockserver.client.MockServerClient; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.Body; +import org.mockserver.model.Headers; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.JsonBody; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.ResponseEntity; + +@SpringBootTest( + classes = {Application.class}, + webEnvironment = WebEnvironment.RANDOM_PORT) +public class GcpAuthExtensionSmokeTest { + + @LocalServerPort private int testApplicationPort; // port at which the spring app is running + + @Autowired private TestRestTemplate template; + + // The port at which the backend server will recieve telemetry + private static final int EXPORTER_ENDPOINT_PORT = 4318; + // The port at which the mock GCP metadata server will run + private static final int MOCK_GCP_METADATA_PORT = 8090; + + // Backend server to which the application under test will export traces + // the export config is specified in the build.gradle file. + private static ClientAndServer backendServer; + + // Mock server to intercept calls to the GCP metadata server and provide fake credentials + private static ClientAndServer mockGcpMetadataServer; + + private static final String METADATA_GOOGLE_INTERNAL = "metadata.google.internal"; + private static final String DUMMY_GCP_QUOTA_PROJECT = System.getenv("GOOGLE_CLOUD_QUOTA_PROJECT"); + private static final String DUMMY_GCP_PROJECT = System.getProperty("google.cloud.project"); + + @BeforeAll + public static void setup() { + // Set up the mock server to always respond with 200 + // Setup proxy host + System.setProperty("http.proxyHost", "localhost"); + System.setProperty("http.proxyPort", MOCK_GCP_METADATA_PORT + ""); + System.setProperty("http.nonProxyHost", "localhost"); + + // Set up mock OTLP backend server to which traces will be exported + backendServer = ClientAndServer.startClientAndServer(EXPORTER_ENDPOINT_PORT); + backendServer.when(request()).respond(response().withStatusCode(200)); + + // Set up the mock gcp metadata server to provide fake credentials + String accessTokenResponse = + "{\"access_token\": \"fake.access_token\",\"expires_in\": 3600, \"token_type\": \"Bearer\"}"; + mockGcpMetadataServer = ClientAndServer.startClientAndServer(MOCK_GCP_METADATA_PORT); + MockServerClient mockServerClient = new MockServerClient("localhost", MOCK_GCP_METADATA_PORT); + mockServerClient + .when( + request() + .withMethod("GET") + .withPath("/computeMetadata/v1/instance/service-accounts/default/token") + .withHeader("Host", METADATA_GOOGLE_INTERNAL) + .withHeader("Metadata-Flavor", "Google")) + .respond( + response() + .withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(new JsonBody(accessTokenResponse))); + } + + @AfterAll + public static void teardown() { + // Stop the backend server + stopQuietly(backendServer); + stopQuietly(mockGcpMetadataServer); + } + + @Test + public void authExtensionSmokeTest() { + ResponseEntity a = + template.getForEntity( + URI.create("http://localhost:" + testApplicationPort + "/ping"), String.class); + System.out.println("resp is " + a.toString()); + + await() + .atMost(10, TimeUnit.SECONDS) + .untilAsserted( + () -> { + HttpRequest[] requests = backendServer.retrieveRecordedRequests(request()); + List extractedHeaders = extractHeadersFromRequests(requests); + verifyRequestHeaders(extractedHeaders); + + List extractedResourceSpans = + extractResourceSpansFromRequests(requests); + verifyResourceAttributes(extractedResourceSpans); + }); + } + + // Helper methods + + private void verifyResourceAttributes(List extractedResourceSpans) { + extractedResourceSpans.forEach( + resourceSpan -> + assertTrue( + resourceSpan + .getResource() + .getAttributesList() + .contains( + KeyValue.newBuilder() + .setKey(GCP_USER_PROJECT_ID_KEY) + .setValue(AnyValue.newBuilder().setStringValue(DUMMY_GCP_PROJECT)) + .build()))); + } + + private void verifyRequestHeaders(List extractedHeaders) { + assertFalse(extractedHeaders.isEmpty()); + // verify if extension added the required headers + extractedHeaders.forEach( + headers -> { + assertTrue(headers.containsEntry(QUOTA_USER_PROJECT_HEADER, DUMMY_GCP_QUOTA_PROJECT)); + assertTrue(headers.containsEntry("Authorization", "Bearer fake.access_token")); + }); + } + + private List extractHeadersFromRequests(HttpRequest[] requests) { + return Arrays.stream(requests).map(HttpRequest::getHeaders).collect(Collectors.toList()); + } + + /** + * Extract resource spans from http requests received by a telemetry collector. + * + * @param requests Request received by a http server trace collector + * @return spans extracted from the request body + */ + private List extractResourceSpansFromRequests(HttpRequest[] requests) { + return Arrays.stream(requests) + .map(HttpRequest::getBody) + .flatMap(body -> getExportTraceServiceRequest(body).stream()) + .flatMap(r -> r.getResourceSpansList().stream()) + .collect(Collectors.toList()); + } + + private Optional getExportTraceServiceRequest(Body body) { + try { + return Optional.ofNullable(ExportTraceServiceRequest.parseFrom(body.getRawBytes())); + } catch (InvalidProtocolBufferException e) { + return Optional.empty(); + } + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java new file mode 100644 index 00000000..e4455c4f --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Application.java @@ -0,0 +1,26 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 + * + * http://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.google.cloud.opentelemetry.extension.auth.springapp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java new file mode 100644 index 00000000..cb8fe88b --- /dev/null +++ b/javaagent-extensions/gcp-auth/src/test/java/com/google/cloud/opentelemetry/extension/auth/springapp/Controller.java @@ -0,0 +1,49 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 + * + * http://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.google.cloud.opentelemetry.extension.auth.springapp; + +import io.opentelemetry.instrumentation.annotations.WithSpan; +import java.time.Duration; +import java.time.Instant; +import java.util.Random; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class Controller { + + private final Random random = new Random(); + + @GetMapping("/ping") + public String ping() { + int busyTime = random.nextInt(200); + long ctr = busyloop(busyTime); + System.out.println("Busy work done, counted " + ctr + " times in " + busyTime + " ms"); + return "pong"; + } + + @WithSpan + private long busyloop(int busyMillis) { + Instant start = Instant.now(); + Instant end; + long counter = 0; + do { + counter++; + end = Instant.now(); + } while (Duration.between(start, end).toMillis() < busyMillis); + return counter; + } +}