From 8ec386efbed0448ace3dc3871ce1b49681ecebc0 Mon Sep 17 00:00:00 2001 From: Robert Stupp Date: Thu, 14 Sep 2023 09:50:40 +0200 Subject: [PATCH] Do not use Google's HTTP client to get the default project ID As described in [this issue](https://github.com/quarkusio/quarkus/issues/35500), Quarkus w/ the Google clooud services and OpenCensus shim does not startup due to a static initialization issue. `com.google.cloud.ServiceOptions` is used by the extension to get the Google cloud default-project-ID, which uses Google's HTTP client, which uses OpenCensus, which triggers an initialization of OpenTelemetry, which conflicts with the OTel init from Quarkus. This change updates `GcpDefaultsConfigSourceFactory` to use Java's HTTP client. See also #487, the alternatives mentioned in [this comment](https://github.com/quarkiverse/quarkus-google-cloud-services/pull/487#issuecomment-1700701468) to implement a `ConfigSource` and in [this comment](https://github.com/quarkiverse/quarkus-google-cloud-services/pull/487#issuecomment-1700784281) to set `otel.java.global-autoconfigure.enabled=false)` end in the same OpenCensus/OpenTracing init race. Fixes https://github.com/quarkusio/quarkus/issues/35500 --- common/runtime/pom.xml | 31 ++++ .../GcpDefaultsConfigSourceFactory.java | 158 +++++++++++++++--- .../GcpDefaultsConfigSourceFactoryTest.java | 59 +++++++ pom.xml | 6 + 4 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 common/runtime/src/test/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactoryTest.java diff --git a/common/runtime/pom.xml b/common/runtime/pom.xml index 972d850f..3368cd6b 100644 --- a/common/runtime/pom.xml +++ b/common/runtime/pom.xml @@ -64,6 +64,28 @@ org.graalvm.sdk graal-sdk + + + + io.quarkus + quarkus-junit5 + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + io.opentelemetry + opentelemetry-opencensus-shim + test + @@ -97,6 +119,15 @@ + + + + + + + + + diff --git a/common/runtime/src/main/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactory.java b/common/runtime/src/main/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactory.java index 5e6b21d5..d1097988 100644 --- a/common/runtime/src/main/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactory.java +++ b/common/runtime/src/main/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactory.java @@ -3,11 +3,23 @@ import static java.util.Collections.emptyList; import static java.util.Collections.singletonList; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; import org.eclipse.microprofile.config.spi.ConfigSource; +import com.google.cloud.PlatformInformation; +import com.google.cloud.ServiceDefaults; import com.google.cloud.ServiceOptions; +import com.google.common.annotations.VisibleForTesting; import io.smallrye.config.ConfigSourceContext; import io.smallrye.config.ConfigSourceFactory; @@ -17,40 +29,138 @@ public class GcpDefaultsConfigSourceFactory implements ConfigSourceFactory { - private static final String OPENTELEMETRY_CONTEXT_CONTEXT_STORAGE_PROVIDER_SYS_PROP = "io.opentelemetry.context.contextStorageProvider"; + private final Supplier defaultProjectIdSupplier; + + public GcpDefaultsConfigSourceFactory() { + this(ServiceOptionsHelper::getDefaultProjectId); + } + + @VisibleForTesting + GcpDefaultsConfigSourceFactory(Supplier defaultProjectIdSupplier) { + this.defaultProjectIdSupplier = defaultProjectIdSupplier; + } @Override public Iterable getConfigSources(final ConfigSourceContext context) { ConfigValue enableMetadataServer = context.getValue("quarkus.google.cloud.enable-metadata-server"); if (enableMetadataServer.getValue() != null) { if (Converters.getImplicitConverter(Boolean.class).convert(enableMetadataServer.getValue())) { - String previousContextStorageSysProp = null; - try { - // Google HTTP Client under the hood which attempts to record traces via OpenCensus which is wired - // to delegate to OpenTelemetry. - // This can lead to problems with the Quarkus OpenTelemetry extension which expects Vert.x to be running, - // something that is not the case at build time, see https://github.com/quarkusio/quarkus/issues/35500 - previousContextStorageSysProp = System.setProperty(OPENTELEMETRY_CONTEXT_CONTEXT_STORAGE_PROVIDER_SYS_PROP, - "default"); - - String defaultProjectId = ServiceOptions.getDefaultProjectId(); - if (defaultProjectId != null) { - return singletonList( - new PropertiesConfigSource(Map.of("quarkus.google.cloud.project-id", defaultProjectId), - "GcpDefaultsConfigSource", - -Integer.MAX_VALUE)); - } - } finally { - if (previousContextStorageSysProp == null) { - System.clearProperty(OPENTELEMETRY_CONTEXT_CONTEXT_STORAGE_PROVIDER_SYS_PROP); - } else { - System.setProperty(OPENTELEMETRY_CONTEXT_CONTEXT_STORAGE_PROVIDER_SYS_PROP, - previousContextStorageSysProp); + String defaultProjectId = defaultProjectIdSupplier.get(); + if (defaultProjectId != null) { + return singletonList( + new PropertiesConfigSource(Map.of("quarkus.google.cloud.project-id", defaultProjectId), + "GcpDefaultsConfigSource", + -Integer.MAX_VALUE)); + } + } + } + return emptyList(); + } + + /** + * This is a partial copy of {@link ServiceOptions} to prevent the use of Google's HTTP client, which causes + * static initialization trouble via OpenCensus-shim with OpenTelemetry. + * + *

+ * This helper class is only intended to not use the Google HTTP client but not change any other aspects of + * how the default project ID is retrieved. + * + *

+ * (The {@link ServiceOptions} class is licensed using ASL2.) + */ + @SuppressWarnings("rawtypes") + static class ServiceOptionsHelper extends ServiceOptions { + @SuppressWarnings("unchecked") + protected ServiceOptionsHelper(Class serviceFactoryClass, Class rpcFactoryClass, Builder builder, + ServiceDefaults serviceDefaults) { + super(serviceFactoryClass, rpcFactoryClass, builder, serviceDefaults); + throw new UnsupportedOperationException(); + } + + @Override + protected Set getScopes() { + throw new UnsupportedOperationException(); + } + + @Override + public Builder toBuilder() { + throw new UnsupportedOperationException(); + } + + public static String getDefaultProjectId() { + // As in the original `ServiceOptions` class + String projectId = System.getProperty("GOOGLE_CLOUD_PROJECT", System.getenv("GOOGLE_CLOUD_PROJECT")); + if (projectId == null) { + projectId = System.getProperty("GCLOUD_PROJECT", System.getenv("GCLOUD_PROJECT")); + } + + if (projectId == null) { + projectId = getAppEngineProjectId(); + } + + if (projectId == null) { + projectId = getServiceAccountProjectId(); + } + + return projectId != null ? projectId : getGoogleCloudProjectId(); + } + + protected static String getAppEngineProjectId() { + // As in the original `ServiceOptions` class + String projectId; + if (PlatformInformation.isOnGAEStandard7()) { + projectId = getAppEngineProjectIdFromAppId(); + } else { + projectId = System.getenv("GOOGLE_CLOUD_PROJECT"); + if (projectId == null) { + projectId = System.getenv("GCLOUD_PROJECT"); + } + + if (projectId == null) { + projectId = getAppEngineProjectIdFromAppId(); + } + + if (projectId == null) { + try { + projectId = getAppEngineProjectIdFromMetadataServer(); + } catch (IOException var2) { + // projectId = null; } } + } + + return projectId; + } + /** + * This function has been changed to use the (new) Java HTTP client. + */ + private static String getAppEngineProjectIdFromMetadataServer() throws IOException { + String metadata = "http://metadata.google.internal"; + String projectIdURL = "/computeMetadata/v1/project/project-id"; + + try { + URI uri = new URI(metadata + projectIdURL); + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(500)).build(); + HttpRequest request = HttpRequest.newBuilder().timeout(Duration.ofMillis(500)).GET().uri(uri) + .header("Metadata-Flavor", "Google").build(); + HttpResponse.BodyHandler bodyHandler = HttpResponse.BodyHandlers.ofString(); + HttpResponse response = client.send(request, bodyHandler); + return headerContainsMetadataFlavor(response) ? response.body() : ""; + } catch (URISyntaxException e) { + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); } } - return emptyList(); + + /** + * This function has been adopted for the Java HTTP client. + */ + private static boolean headerContainsMetadataFlavor(HttpResponse response) { + String metadataFlavorValue = response.headers().firstValue("Metadata-Flavor").orElse(""); + return "Google".equals(metadataFlavorValue); + } } } diff --git a/common/runtime/src/test/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactoryTest.java b/common/runtime/src/test/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactoryTest.java new file mode 100644 index 00000000..36b82e32 --- /dev/null +++ b/common/runtime/src/test/java/io/quarkiverse/googlecloudservices/common/GcpDefaultsConfigSourceFactoryTest.java @@ -0,0 +1,59 @@ +package io.quarkiverse.googlecloudservices.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.microprofile.config.spi.ConfigSource; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.OpenTelemetry; +import io.smallrye.config.ConfigSourceContext; +import io.smallrye.config.ConfigValue; + +class GcpDefaultsConfigSourceFactoryTest { + @Test + void configSourceWorks() { + ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); + Mockito.when(context.getValue("quarkus.google.cloud.enable-metadata-server")) + .thenReturn(ConfigValue.builder().withValue("true").build()); + + Iterable configSources = new GcpDefaultsConfigSourceFactory(() -> "test-project-id") + .getConfigSources(context); + assertThat(configSources).asList().hasSize(1); + + ConfigSource configSource = configSources.iterator().next(); + assertThat(configSource.getProperties()).containsEntry("quarkus.google.cloud.project-id", "test-project-id"); + } + + @Test + void metadataServerDisabled() { + ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); + Mockito.when(context.getValue("quarkus.google.cloud.enable-metadata-server")) + .thenReturn(ConfigValue.builder().withValue("false").build()); + + Iterable configSources = new GcpDefaultsConfigSourceFactory(() -> "test-project-id") + .getConfigSources(context); + assertThat(configSources).isEmpty(); + } + + @Test + void staticOpenCensusOpenTelemetryInit() { + try { + GlobalOpenTelemetry.resetForTest(); + + ConfigSourceContext context = Mockito.mock(ConfigSourceContext.class); + Mockito.when(context.getValue("quarkus.google.cloud.enable-metadata-server")) + .thenReturn(ConfigValue.builder().withValue("true").build()); + + // Uses the "real" implementation that tries to fetch the default-project-id + Iterable configSources = new GcpDefaultsConfigSourceFactory().getConfigSources(context); + assertThat(configSources).asList().hasSizeLessThanOrEqualTo(1); + + // This is a pretty ugly way, because it changes static state + GlobalOpenTelemetry.set(OpenTelemetry.noop()); + } finally { + GlobalOpenTelemetry.resetForTest(); + } + } +} diff --git a/pom.xml b/pom.xml index f573d280..4f47bbd4 100644 --- a/pom.xml +++ b/pom.xml @@ -21,6 +21,7 @@ 3.8.1 3.4.1 3.24.2 + 1.28.0-alpha scm:git:git@github.com:quarkiverse/quarkus-google-cloud-services.git @@ -63,6 +64,11 @@ assertj-core ${assertj.version} + + io.opentelemetry + opentelemetry-opencensus-shim + ${opentelemetry-alpha.version} +