Skip to content

Commit ff6a780

Browse files
[gcp-auth-extension]: Try resolving GCP_PROJECT from Google credentials if not provided (#2109)
1 parent 9f33420 commit ff6a780

File tree

4 files changed

+202
-8
lines changed

4 files changed

+202
-8
lines changed

gcp-auth-extension/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,13 @@ The extension can be configured either by environment variables or system proper
3434

3535
Here is a list of required and optional configuration available for the extension:
3636

37-
#### Required Config
37+
#### Optional Config
3838

3939
- `GOOGLE_CLOUD_PROJECT`: Environment variable that represents the Google Cloud Project ID to which the telemetry needs to be exported.
4040

4141
- Can also be configured using `google.cloud.project` system property.
42-
- This is a required option, the agent configuration will fail if this option is not set.
43-
44-
#### Optional Config
42+
- If neither of these options are set, the extension will attempt to infer the project id from the current credentials as a fallback, however notice that not all credentials implementations will be able to provide a project id, so the inference is only a best-effort attempt.
43+
- **Important Note**: The agent configuration will fail if this option is not set and cannot be inferred.
4544

4645
- `GOOGLE_CLOUD_QUOTA_PROJECT`: Environment variable that represents the Google Cloud Quota Project ID which will be charged for the GCP API usage. To learn more about a *quota project*, see the [Quota project overview](https://cloud.google.com/docs/quotas/quota-project) page. Additional details about configuring the *quota project* can be found on the [Set the quota project](https://cloud.google.com/docs/quotas/set-quota-project) page.
4746

gcp-auth-extension/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ dependencies {
3131
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
3232
testImplementation("org.junit.jupiter:junit-jupiter-api")
3333
testCompileOnly("org.junit.jupiter:junit-jupiter-params")
34+
testImplementation("org.junit-pioneer:junit-pioneer")
3435

3536
testImplementation("io.opentelemetry:opentelemetry-api")
3637
testImplementation("io.opentelemetry:opentelemetry-exporter-otlp")

gcp-auth-extension/src/main/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProvider.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizer;
2626
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider;
2727
import io.opentelemetry.sdk.autoconfigure.spi.ConfigProperties;
28+
import io.opentelemetry.sdk.autoconfigure.spi.ConfigurationException;
2829
import io.opentelemetry.sdk.metrics.export.MetricExporter;
2930
import io.opentelemetry.sdk.resources.Resource;
3031
import io.opentelemetry.sdk.trace.export.SpanExporter;
@@ -111,7 +112,9 @@ public void customize(@Nonnull AutoConfigurationCustomizer autoConfiguration) {
111112
.addMetricExporterCustomizer(
112113
(metricExporter, configProperties) ->
113114
customizeMetricExporter(metricExporter, credentials, configProperties))
114-
.addResourceCustomizer(GcpAuthAutoConfigurationCustomizerProvider::customizeResource);
115+
.addResourceCustomizer(
116+
(resource, configProperties) ->
117+
customizeResource(resource, credentials, configProperties));
115118
}
116119

117120
@Override
@@ -228,9 +231,17 @@ private static Map<String, String> getRequiredHeaderMap(
228231
}
229232

230233
// Updates the current resource with the attributes required for ingesting OTLP data on GCP.
231-
private static Resource customizeResource(Resource resource, ConfigProperties configProperties) {
232-
String gcpProjectId =
233-
ConfigurableOption.GOOGLE_CLOUD_PROJECT.getConfiguredValue(configProperties);
234+
private static Resource customizeResource(
235+
Resource resource, GoogleCredentials credentials, ConfigProperties configProperties) {
236+
String gcpProjectId;
237+
try {
238+
gcpProjectId = ConfigurableOption.GOOGLE_CLOUD_PROJECT.getConfiguredValue(configProperties);
239+
} catch (ConfigurationException e) {
240+
gcpProjectId = credentials.getProjectId();
241+
if (gcpProjectId == null || gcpProjectId.isEmpty()) {
242+
throw e;
243+
}
244+
}
234245
Resource res = Resource.create(Attributes.of(stringKey(GCP_USER_PROJECT_ID_KEY), gcpProjectId));
235246
return resource.merge(res);
236247
}

gcp-auth-extension/src/test/java/io/opentelemetry/contrib/gcp/auth/GcpAuthAutoConfigurationCustomizerProviderTest.java

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
import org.junit.jupiter.params.ParameterizedTest;
7575
import org.junit.jupiter.params.provider.Arguments;
7676
import org.junit.jupiter.params.provider.MethodSource;
77+
import org.junitpioneer.jupiter.ClearSystemProperty;
7778
import org.mockito.ArgumentCaptor;
7879
import org.mockito.Captor;
7980
import org.mockito.Mock;
@@ -456,6 +457,86 @@ void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) throws IOExce
456457
}
457458
}
458459

460+
@ParameterizedTest
461+
@MethodSource("provideProjectIdBehaviorTestCases")
462+
@ClearSystemProperty(key = "google.cloud.project")
463+
@ClearSystemProperty(key = "google.otel.auth.target.signals")
464+
@SuppressWarnings("CannotMockMethod")
465+
void testProjectIdBehavior(ProjectIdTestBehavior testCase) throws IOException {
466+
467+
// configure environment according to test case
468+
String userSpecifiedProjectId = testCase.getUserSpecifiedProjectId();
469+
if (userSpecifiedProjectId != null) {
470+
System.setProperty(
471+
ConfigurableOption.GOOGLE_CLOUD_PROJECT.getSystemProperty(), userSpecifiedProjectId);
472+
}
473+
System.setProperty(
474+
ConfigurableOption.GOOGLE_OTEL_AUTH_TARGET_SIGNALS.getSystemProperty(), SIGNAL_TYPE_TRACES);
475+
476+
// prepare request metadata (may or may not be called depending on test scenario)
477+
AccessToken fakeAccessToken = new AccessToken("fake", Date.from(Instant.now()));
478+
ImmutableMap<String, List<String>> mockedRequestMetadata =
479+
ImmutableMap.of(
480+
"Authorization",
481+
Collections.singletonList("Bearer " + fakeAccessToken.getTokenValue()));
482+
Mockito.lenient()
483+
.when(mockedGoogleCredentials.getRequestMetadata())
484+
.thenReturn(mockedRequestMetadata);
485+
486+
// only mock getProjectId() if it will be called (i.e., user didn't specify project ID)
487+
boolean shouldFallbackToCredentials =
488+
userSpecifiedProjectId == null || userSpecifiedProjectId.isEmpty();
489+
if (shouldFallbackToCredentials) {
490+
Mockito.when(mockedGoogleCredentials.getProjectId())
491+
.thenReturn(testCase.getCredentialsProjectId());
492+
}
493+
494+
// prepare mock exporter
495+
OtlpGrpcSpanExporter mockOtlpGrpcSpanExporter = Mockito.mock(OtlpGrpcSpanExporter.class);
496+
OtlpGrpcSpanExporterBuilder spyOtlpGrpcSpanExporterBuilder =
497+
Mockito.spy(OtlpGrpcSpanExporter.builder());
498+
List<SpanData> exportedSpans = new ArrayList<>();
499+
configureGrpcMockSpanExporter(
500+
mockOtlpGrpcSpanExporter, spyOtlpGrpcSpanExporterBuilder, exportedSpans);
501+
502+
try (MockedStatic<GoogleCredentials> googleCredentialsMockedStatic =
503+
Mockito.mockStatic(GoogleCredentials.class)) {
504+
googleCredentialsMockedStatic
505+
.when(GoogleCredentials::getApplicationDefault)
506+
.thenReturn(mockedGoogleCredentials);
507+
508+
if (testCase.getExpectedToThrow()) {
509+
// expect exception to be thrown when project ID is not available
510+
assertThatThrownBy(() -> buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter))
511+
.isInstanceOf(ConfigurationException.class);
512+
// verify getProjectId() was called to attempt fallback
513+
Mockito.verify(mockedGoogleCredentials, Mockito.times(1)).getProjectId();
514+
} else {
515+
// export telemetry and verify resource attributes contain expected project ID
516+
OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter(mockOtlpGrpcSpanExporter);
517+
generateTestSpan(sdk);
518+
CompletableResultCode code = sdk.shutdown();
519+
CompletableResultCode joinResult = code.join(10, TimeUnit.SECONDS);
520+
assertThat(joinResult.isSuccess()).isTrue();
521+
522+
assertThat(exportedSpans).hasSizeGreaterThan(0);
523+
for (SpanData spanData : exportedSpans) {
524+
assertThat(spanData.getResource().getAttributes().asMap())
525+
.containsEntry(
526+
AttributeKey.stringKey(GCP_USER_PROJECT_ID_KEY),
527+
testCase.getExpectedProjectIdInResource());
528+
}
529+
530+
// verify whether getProjectId() was called based on whether fallback was needed
531+
if (shouldFallbackToCredentials) {
532+
Mockito.verify(mockedGoogleCredentials, Mockito.times(1)).getProjectId();
533+
} else {
534+
Mockito.verify(mockedGoogleCredentials, Mockito.never()).getProjectId();
535+
}
536+
}
537+
}
538+
}
539+
459540
@ParameterizedTest
460541
@MethodSource("provideTargetSignalBehaviorTestCases")
461542
void testTargetSignalsBehavior(TargetSignalBehavior testCase) {
@@ -665,6 +746,72 @@ private static Stream<Arguments> provideTargetSignalBehaviorTestCases() {
665746
.build()));
666747
}
667748

749+
/**
750+
* Test cases specifying expected value for the project ID in the resource given the user input
751+
* and the current credentials state.
752+
*
753+
* <p>{@code null} for {@link ProjectIdTestBehavior#getUserSpecifiedProjectId()} indicates the
754+
* case of user not specifying the project ID.
755+
*
756+
* <p>{@code null} value for {@link ProjectIdTestBehavior#getCredentialsProjectId()} indicates
757+
* that the mocked credentials are not providing a project ID.
758+
*
759+
* <p>{@code true} for {@link ProjectIdTestBehavior#getExpectedToThrow()} indicates the
760+
* expectation that an exception should be thrown.
761+
*/
762+
private static Stream<Arguments> provideProjectIdBehaviorTestCases() {
763+
return Stream.of(
764+
// User specified project ID takes precedence
765+
Arguments.of(
766+
ProjectIdTestBehavior.builder()
767+
.setUserSpecifiedProjectId(DUMMY_GCP_RESOURCE_PROJECT_ID)
768+
.setCredentialsProjectId("credentials-project-id")
769+
.setExpectedProjectIdInResource(DUMMY_GCP_RESOURCE_PROJECT_ID)
770+
.setExpectedToThrow(false)
771+
.build()),
772+
// If user specified project ID is empty, fallback to credentials.getProjectId()
773+
Arguments.of(
774+
ProjectIdTestBehavior.builder()
775+
.setUserSpecifiedProjectId("")
776+
.setCredentialsProjectId("credentials-project-id")
777+
.setExpectedProjectIdInResource("credentials-project-id")
778+
.setExpectedToThrow(false)
779+
.build()),
780+
// If user doesn't specify project ID, fallback to credentials.getProjectId()
781+
Arguments.of(
782+
ProjectIdTestBehavior.builder()
783+
.setUserSpecifiedProjectId(null)
784+
.setCredentialsProjectId("credentials-project-id")
785+
.setExpectedProjectIdInResource("credentials-project-id")
786+
.setExpectedToThrow(false)
787+
.build()),
788+
// If user doesn't specify and credentials.getProjectId() returns null, throw exception
789+
Arguments.of(
790+
ProjectIdTestBehavior.builder()
791+
.setUserSpecifiedProjectId(null)
792+
.setCredentialsProjectId(null)
793+
.setExpectedProjectIdInResource(null)
794+
.setExpectedToThrow(true)
795+
.build()),
796+
// If user specified project ID is empty and credentials.getProjectId() returns null, throw
797+
// exception
798+
Arguments.of(
799+
ProjectIdTestBehavior.builder()
800+
.setUserSpecifiedProjectId("")
801+
.setCredentialsProjectId(null)
802+
.setExpectedProjectIdInResource(null)
803+
.setExpectedToThrow(true)
804+
.build()),
805+
// If user specifies empty and credentials returns empty (edge case), throw exception
806+
Arguments.of(
807+
ProjectIdTestBehavior.builder()
808+
.setUserSpecifiedProjectId("")
809+
.setCredentialsProjectId("")
810+
.setExpectedProjectIdInResource(null)
811+
.setExpectedToThrow(true)
812+
.build()));
813+
}
814+
668815
/**
669816
* Test cases specifying expected value for the user quota project header given the user input and
670817
* the current credentials state.
@@ -839,6 +986,42 @@ private static void configureGrpcMockMetricExporter(
839986
.thenReturn(MemoryMode.IMMUTABLE_DATA);
840987
}
841988

989+
@AutoValue
990+
abstract static class ProjectIdTestBehavior {
991+
// A null user specified project ID represents the use case where user omits specifying it
992+
@Nullable
993+
abstract String getUserSpecifiedProjectId();
994+
995+
// The project ID that credentials.getProjectId() returns (can be null)
996+
@Nullable
997+
abstract String getCredentialsProjectId();
998+
999+
// The expected project ID in the resource attributes (null if exception expected)
1000+
@Nullable
1001+
abstract String getExpectedProjectIdInResource();
1002+
1003+
// Whether an exception is expected to be thrown
1004+
abstract boolean getExpectedToThrow();
1005+
1006+
static Builder builder() {
1007+
return new AutoValue_GcpAuthAutoConfigurationCustomizerProviderTest_ProjectIdTestBehavior
1008+
.Builder();
1009+
}
1010+
1011+
@AutoValue.Builder
1012+
abstract static class Builder {
1013+
abstract Builder setUserSpecifiedProjectId(String projectId);
1014+
1015+
abstract Builder setCredentialsProjectId(String projectId);
1016+
1017+
abstract Builder setExpectedProjectIdInResource(String projectId);
1018+
1019+
abstract Builder setExpectedToThrow(boolean expectedToThrow);
1020+
1021+
abstract ProjectIdTestBehavior build();
1022+
}
1023+
}
1024+
8421025
@AutoValue
8431026
abstract static class QuotaProjectIdTestBehavior {
8441027
// A null user specified quota represents the use case where user omits specifying quota

0 commit comments

Comments
 (0)