Skip to content

Commit 335c267

Browse files
frankynBenWhitehead
andauthoredApr 1, 2022
fix: add gccl-invocation-id interceptor (#1309)
add gccl-invocation-id to all HTTP requests to allow identifying operation attempts across multiple rpcs. Excludes signed urls. Co-authored-by: BenWhitehead <BenWhitehead@users.noreply.github.com>
1 parent 9d8c520 commit 335c267

File tree

7 files changed

+375
-2
lines changed

7 files changed

+375
-2
lines changed
 

‎google-cloud-storage/pom.xml

+4
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@
9292
<groupId>com.fasterxml.jackson.core</groupId>
9393
<artifactId>jackson-core</artifactId>
9494
</dependency>
95+
<dependency>
96+
<groupId>com.google.code.findbugs</groupId>
97+
<artifactId>jsr305</artifactId>
98+
</dependency>
9599

96100
<!-- Test dependencies -->
97101
<dependency>

‎google-cloud-storage/src/main/java/com/google/cloud/storage/Retrying.java

+5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import com.google.api.gax.retrying.ResultRetryAlgorithm;
2323
import com.google.api.gax.retrying.RetrySettings;
2424
import com.google.cloud.RetryHelper.RetryHelperException;
25+
import com.google.cloud.storage.spi.v1.HttpRpcContext;
2526
import java.util.concurrent.Callable;
2627
import java.util.function.Function;
2728

@@ -47,11 +48,15 @@ final class Retrying {
4748
*/
4849
static <T, U> U run(
4950
StorageOptions options, ResultRetryAlgorithm<?> algorithm, Callable<T> c, Function<T, U> f) {
51+
HttpRpcContext httpRpcContext = HttpRpcContext.getInstance();
5052
try {
53+
httpRpcContext.newInvocationId();
5154
T result = runWithRetries(c, options.getRetrySettings(), algorithm, options.getClock());
5255
return result == null ? null : f.apply(result);
5356
} catch (RetryHelperException e) {
5457
throw StorageException.coalesce(e);
58+
} finally {
59+
httpRpcContext.clearInvocationId();
5560
}
5661
}
5762
}

‎google-cloud-storage/src/main/java/com/google/cloud/storage/StorageOptions.java

+28-2
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,9 @@ public class StorageOptions extends ServiceOptions<Storage, StorageOptions> {
3838
private static final String GCS_SCOPE = "https://www.googleapis.com/auth/devstorage.full_control";
3939
private static final Set<String> SCOPES = ImmutableSet.of(GCS_SCOPE);
4040
private static final String DEFAULT_HOST = "https://storage.googleapis.com";
41-
41+
private static final boolean DEFAULT_INCLUDE_INVOCATION_ID = true;
4242
private final RetryAlgorithmManager retryAlgorithmManager;
43+
private final boolean includeInvocationId;
4344

4445
public static class DefaultStorageFactory implements StorageFactory {
4546

@@ -64,11 +65,13 @@ public ServiceRpc create(StorageOptions options) {
6465
public static class Builder extends ServiceOptions.Builder<Storage, StorageOptions, Builder> {
6566

6667
private StorageRetryStrategy storageRetryStrategy;
68+
private boolean includeInvocationId;
6769

6870
private Builder() {}
6971

7072
private Builder(StorageOptions options) {
7173
super(options);
74+
this.includeInvocationId = options.includeInvocationId;
7275
}
7376

7477
@Override
@@ -93,6 +96,17 @@ public Builder setStorageRetryStrategy(StorageRetryStrategy storageRetryStrategy
9396
return this;
9497
}
9598

99+
/**
100+
* Override default enablement of invocation id added to x-goog-api-client header.
101+
*
102+
* @param includeInvocationId a boolean to change enablement of invocation id
103+
* @return the builder
104+
*/
105+
public Builder setIncludeInvocationId(boolean includeInvocationId) {
106+
this.includeInvocationId = includeInvocationId;
107+
return this;
108+
}
109+
96110
@Override
97111
public StorageOptions build() {
98112
return new StorageOptions(this, new StorageDefaults());
@@ -105,6 +119,7 @@ private StorageOptions(Builder builder, StorageDefaults serviceDefaults) {
105119
new RetryAlgorithmManager(
106120
MoreObjects.firstNonNull(
107121
builder.storageRetryStrategy, serviceDefaults.getStorageRetryStrategy()));
122+
this.includeInvocationId = builder.includeInvocationId;
108123
}
109124

110125
private static class StorageDefaults implements ServiceDefaults<Storage, StorageOptions> {
@@ -127,6 +142,10 @@ public TransportOptions getDefaultTransportOptions() {
127142
public StorageRetryStrategy getStorageRetryStrategy() {
128143
return StorageRetryStrategy.getDefaultStorageRetryStrategy();
129144
}
145+
146+
public boolean isIncludeInvocationId() {
147+
return DEFAULT_INCLUDE_INVOCATION_ID;
148+
}
130149
}
131150

132151
public static HttpTransportOptions getDefaultHttpTransportOptions() {
@@ -153,6 +172,11 @@ RetryAlgorithmManager getRetryAlgorithmManager() {
153172
return retryAlgorithmManager;
154173
}
155174

175+
/** Returns if Invocation ID is enabled and transmitted through x-goog-api-client header. */
176+
public boolean isIncludeInvocationId() {
177+
return includeInvocationId;
178+
}
179+
156180
/** Returns a default {@code StorageOptions} instance. */
157181
public static StorageOptions getDefaultInstance() {
158182
return newBuilder().build();
@@ -180,6 +204,8 @@ public boolean equals(Object obj) {
180204
}
181205

182206
public static Builder newBuilder() {
183-
return new Builder().setHost(DEFAULT_HOST);
207+
return new Builder()
208+
.setHost(DEFAULT_HOST)
209+
.setIncludeInvocationId(DEFAULT_INCLUDE_INVOCATION_ID);
184210
}
185211
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2022 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage.spi.v1;
18+
19+
import com.google.api.core.InternalApi;
20+
import java.util.UUID;
21+
import java.util.function.Supplier;
22+
import javax.annotation.Nullable;
23+
24+
@InternalApi
25+
public final class HttpRpcContext {
26+
27+
private static final Object GET_INSTANCE_LOCK = new Object();
28+
29+
private static volatile HttpRpcContext instance;
30+
31+
private final ThreadLocal<UUID> invocationId;
32+
private final Supplier<UUID> supplier;
33+
34+
HttpRpcContext(Supplier<UUID> randomUUID) {
35+
this.invocationId = new InheritableThreadLocal<>();
36+
this.supplier = randomUUID;
37+
}
38+
39+
@InternalApi
40+
@Nullable
41+
public UUID getInvocationId() {
42+
return invocationId.get();
43+
}
44+
45+
@InternalApi
46+
public UUID newInvocationId() {
47+
invocationId.set(supplier.get());
48+
return getInvocationId();
49+
}
50+
51+
@InternalApi
52+
public void clearInvocationId() {
53+
invocationId.remove();
54+
}
55+
56+
@InternalApi
57+
public static HttpRpcContext init() {
58+
return new HttpRpcContext(UUID::randomUUID);
59+
}
60+
61+
@InternalApi
62+
public static HttpRpcContext getInstance() {
63+
if (instance == null) {
64+
synchronized (GET_INSTANCE_LOCK) {
65+
if (instance == null) {
66+
instance = init();
67+
}
68+
}
69+
}
70+
return instance;
71+
}
72+
}

‎google-cloud-storage/src/main/java/com/google/cloud/storage/spi/v1/HttpStorageRpc.java

+55
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import static com.google.common.base.MoreObjects.firstNonNull;
2020
import static com.google.common.base.Preconditions.checkArgument;
21+
import static com.google.common.base.Preconditions.checkNotNull;
2122
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
2223

2324
import com.google.api.client.googleapis.batch.BatchRequest;
@@ -26,6 +27,7 @@
2627
import com.google.api.client.http.ByteArrayContent;
2728
import com.google.api.client.http.EmptyContent;
2829
import com.google.api.client.http.GenericUrl;
30+
import com.google.api.client.http.HttpExecuteInterceptor;
2931
import com.google.api.client.http.HttpHeaders;
3032
import com.google.api.client.http.HttpRequest;
3133
import com.google.api.client.http.HttpRequestFactory;
@@ -86,6 +88,8 @@
8688
import java.util.List;
8789
import java.util.Locale;
8890
import java.util.Map;
91+
import java.util.UUID;
92+
import javax.annotation.Nullable;
8993

9094
public class HttpStorageRpc implements StorageRpc {
9195
public static final String DEFAULT_PROJECTION = "full";
@@ -114,6 +118,9 @@ public HttpStorageRpc(StorageOptions options) {
114118
// Open Census initialization
115119
censusHttpModule = new CensusHttpModule(tracer, true);
116120
initializer = censusHttpModule.getHttpRequestInitializer(initializer);
121+
if (options.isIncludeInvocationId()) {
122+
initializer = new InvocationIdInitializer(initializer);
123+
}
117124
batchRequestInitializer = censusHttpModule.getHttpRequestInitializer(null);
118125
storage =
119126
new Storage.Builder(transport, new JacksonFactory(), initializer)
@@ -122,6 +129,54 @@ public HttpStorageRpc(StorageOptions options) {
122129
.build();
123130
}
124131

132+
private static final class InvocationIdInitializer implements HttpRequestInitializer {
133+
@Nullable HttpRequestInitializer initializer;
134+
135+
private InvocationIdInitializer(@Nullable HttpRequestInitializer initializer) {
136+
this.initializer = initializer;
137+
}
138+
139+
@Override
140+
public void initialize(HttpRequest request) throws IOException {
141+
checkNotNull(request);
142+
if (this.initializer != null) {
143+
this.initializer.initialize(request);
144+
}
145+
request.setInterceptor(new InvocationIdInterceptor(request.getInterceptor()));
146+
}
147+
}
148+
149+
private static final class InvocationIdInterceptor implements HttpExecuteInterceptor {
150+
@Nullable HttpExecuteInterceptor interceptor;
151+
152+
private InvocationIdInterceptor(@Nullable HttpExecuteInterceptor interceptor) {
153+
this.interceptor = interceptor;
154+
}
155+
156+
@Override
157+
public void intercept(HttpRequest request) throws IOException {
158+
checkNotNull(request);
159+
if (this.interceptor != null) {
160+
this.interceptor.intercept(request);
161+
}
162+
UUID invocationId = HttpRpcContext.getInstance().getInvocationId();
163+
final String signatureKey = "Signature="; // For V2 and V4 signedURLs
164+
final String builtURL = request.getUrl().build();
165+
if (invocationId != null && !builtURL.contains(signatureKey)) {
166+
HttpHeaders headers = request.getHeaders();
167+
String existing = (String) headers.get("x-goog-api-client");
168+
String invocationEntry = "gccl-invocation-id/" + invocationId;
169+
final String newValue;
170+
if (existing != null && !existing.isEmpty()) {
171+
newValue = existing + " " + invocationEntry;
172+
} else {
173+
newValue = invocationEntry;
174+
}
175+
headers.set("x-goog-api-client", newValue);
176+
}
177+
}
178+
}
179+
125180
private class DefaultRpcBatch implements RpcBatch {
126181

127182
// Batch size is limited as, due to some current service implementation details, the service

‎google-cloud-storage/src/test/java/com/google/cloud/storage/StorageOptionsTest.java

+16
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package com.google.cloud.storage;
1818

1919
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertFalse;
21+
import static org.junit.Assert.assertTrue;
2022

2123
import com.google.cloud.TransportOptions;
2224
import org.easymock.EasyMock;
@@ -65,4 +67,18 @@ public void testDefaultInstanceSpecifiesCorrectHost() {
6567

6668
assertThat(opts1.getHost()).isEqualTo("https://storage.googleapis.com");
6769
}
70+
71+
@Test
72+
public void testDefaultInvocationId() {
73+
StorageOptions opts1 = StorageOptions.getDefaultInstance();
74+
75+
assertTrue(opts1.isIncludeInvocationId());
76+
}
77+
78+
@Test
79+
public void testDisableInvocationId() {
80+
StorageOptions opts1 = StorageOptions.newBuilder().setIncludeInvocationId(false).build();
81+
82+
assertFalse(opts1.isIncludeInvocationId());
83+
}
6884
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
* Copyright 2022 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.cloud.storage.spi.v1;
18+
19+
import static com.google.common.truth.Truth.assertThat;
20+
import static org.junit.Assert.assertFalse;
21+
import static org.junit.Assert.assertTrue;
22+
23+
import com.google.api.client.http.HttpTransport;
24+
import com.google.api.client.http.LowLevelHttpRequest;
25+
import com.google.api.client.http.LowLevelHttpResponse;
26+
import com.google.api.client.testing.http.MockLowLevelHttpResponse;
27+
import com.google.cloud.TransportOptions;
28+
import com.google.cloud.Tuple;
29+
import com.google.cloud.WriteChannel;
30+
import com.google.cloud.http.HttpTransportOptions;
31+
import com.google.cloud.storage.Storage;
32+
import com.google.cloud.storage.StorageOptions;
33+
import com.google.common.collect.ImmutableList;
34+
import java.io.IOException;
35+
import java.net.URL;
36+
import java.nio.ByteBuffer;
37+
import java.nio.charset.StandardCharsets;
38+
import java.util.ArrayList;
39+
import java.util.Collections;
40+
import java.util.List;
41+
import java.util.Optional;
42+
import java.util.UUID;
43+
import org.junit.Test;
44+
45+
public class HttpRpcContextTest {
46+
@Test
47+
public void testNewInvocationId() {
48+
UUID uuid = UUID.fromString("28220dff-1e8b-4770-9e10-022c2a99d8f3");
49+
HttpRpcContext testContext = new HttpRpcContext(() -> uuid);
50+
51+
assertThat(testContext.newInvocationId()).isEqualTo(uuid);
52+
assertThat(testContext.getInvocationId()).isEqualTo(uuid);
53+
// call again to ensure the id is consistent with our supplier
54+
assertThat(testContext.newInvocationId()).isEqualTo(uuid);
55+
assertThat(testContext.getInvocationId()).isEqualTo(uuid);
56+
}
57+
58+
@Test
59+
public void testInvocationIdIsPassedThrough() {
60+
MockLowLevelHttpResponse response =
61+
new MockLowLevelHttpResponse()
62+
.setContentType("application/json")
63+
.setContent(
64+
"{\n"
65+
+ " \"kind\": \"storage#serviceAccount\",\n"
66+
+ " \"email_address\": \"service-234234@gs-project-accounts.iam.gserviceaccount.com\"\n"
67+
+ "}\n")
68+
.setStatusCode(200);
69+
AuditingHttpTransport transport = new AuditingHttpTransport(response);
70+
TransportOptions transportOptions =
71+
HttpTransportOptions.newBuilder().setHttpTransportFactory(() -> transport).build();
72+
Storage service =
73+
StorageOptions.getDefaultInstance()
74+
.toBuilder()
75+
.setTransportOptions(transportOptions)
76+
.build()
77+
.getService();
78+
service.getServiceAccount("test-project");
79+
Optional<Tuple<String, String>> anyXGoogApiClientWithGcclInvocationId =
80+
transport.getAddHeaderCalls().stream()
81+
.filter(t -> "x-goog-api-client".equals(t.x()) && t.y().contains("gccl-invocation-id/"))
82+
.findFirst();
83+
assertTrue(anyXGoogApiClientWithGcclInvocationId.isPresent());
84+
assertThat(transport.getBuildRequestCalls()).hasSize(1);
85+
}
86+
87+
@Test
88+
public void testInvocationIdIsNotPassedThroughWhenDisabled() {
89+
MockLowLevelHttpResponse response =
90+
new MockLowLevelHttpResponse()
91+
.setContentType("application/json")
92+
.setContent(
93+
"{\n"
94+
+ " \"kind\": \"storage#serviceAccount\",\n"
95+
+ " \"email_address\": \"service-234234@gs-project-accounts.iam.gserviceaccount.com\"\n"
96+
+ "}\n")
97+
.setStatusCode(200);
98+
AuditingHttpTransport transport = new AuditingHttpTransport(response);
99+
TransportOptions transportOptions =
100+
HttpTransportOptions.newBuilder().setHttpTransportFactory(() -> transport).build();
101+
Storage service =
102+
StorageOptions.getDefaultInstance()
103+
.toBuilder()
104+
.setTransportOptions(transportOptions)
105+
.setIncludeInvocationId(false)
106+
.build()
107+
.getService();
108+
service.getServiceAccount("test-project");
109+
Optional<Tuple<String, String>> anyXGoogApiClientWithGcclInvocationId =
110+
transport.getAddHeaderCalls().stream()
111+
.filter(t -> "x-goog-api-client".equals(t.x()) && t.y().contains("gccl-invocation-id/"))
112+
.findFirst();
113+
114+
assertFalse(anyXGoogApiClientWithGcclInvocationId.isPresent());
115+
assertThat(transport.getBuildRequestCalls()).hasSize(1);
116+
}
117+
118+
@Test
119+
public void testInvocationIdNotInSignedURL_v2() throws IOException {
120+
URL signedUrlV2 =
121+
new URL(
122+
"http://www.test.com/test-bucket/test1.txt?GoogleAccessId=testClient-test@test.com&Expires=1553839761&Signature=MJUBXAZ7");
123+
doTestInvocationIdNotInSignedURL(signedUrlV2);
124+
}
125+
126+
@Test
127+
public void testInvocationIdNotInSignedURL_v4() throws IOException {
128+
URL signedUrlV4 =
129+
new URL(
130+
"http://www.test.com/test-bucket/test1.txt?X-Goog-Algorithm=&X-Goog-Credential=&X-Goog-Date=&X-Goog-Expires=&X-Goog-SignedHeaders=&X-Goog-Signature=MJUBXAZ7");
131+
doTestInvocationIdNotInSignedURL(signedUrlV4);
132+
}
133+
134+
private void doTestInvocationIdNotInSignedURL(URL signedUrl) throws IOException {
135+
MockLowLevelHttpResponse response =
136+
new MockLowLevelHttpResponse()
137+
.setContentType("text/plain")
138+
.setHeaderNames(ImmutableList.of("Location"))
139+
.setHeaderValues(ImmutableList.of("http://test"))
140+
.setStatusCode(201);
141+
AuditingHttpTransport transport = new AuditingHttpTransport(response);
142+
TransportOptions transportOptions =
143+
HttpTransportOptions.newBuilder().setHttpTransportFactory(() -> transport).build();
144+
Storage service =
145+
StorageOptions.getDefaultInstance()
146+
.toBuilder()
147+
.setTransportOptions(transportOptions)
148+
.build()
149+
.getService();
150+
WriteChannel writer = service.writer(signedUrl);
151+
writer.write(ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8)));
152+
Optional<Tuple<String, String>> anyXGoogApiClientWithGcclInvocationId =
153+
transport.getAddHeaderCalls().stream()
154+
.filter(t -> "x-goog-api-client".equals(t.x()) && t.y().contains("gccl-invocation-id/"))
155+
.findFirst();
156+
assertFalse(anyXGoogApiClientWithGcclInvocationId.isPresent());
157+
assertThat(transport.getBuildRequestCalls()).hasSize(1);
158+
}
159+
160+
private static final class AuditingHttpTransport extends HttpTransport {
161+
private final LowLevelHttpResponse response;
162+
private final List<Tuple<String, String>> buildRequestCalls;
163+
private final List<Tuple<String, String>> addHeaderCalls;
164+
165+
private AuditingHttpTransport(LowLevelHttpResponse response) {
166+
this.response = response;
167+
this.buildRequestCalls = Collections.synchronizedList(new ArrayList<>());
168+
this.addHeaderCalls = Collections.synchronizedList(new ArrayList<>());
169+
}
170+
171+
public List<Tuple<String, String>> getBuildRequestCalls() {
172+
return ImmutableList.copyOf(buildRequestCalls);
173+
}
174+
175+
public List<Tuple<String, String>> getAddHeaderCalls() {
176+
return ImmutableList.copyOf(addHeaderCalls);
177+
}
178+
179+
@Override
180+
protected LowLevelHttpRequest buildRequest(String method, String url) {
181+
buildRequestCalls.add(Tuple.of(method, url));
182+
return new LowLevelHttpRequest() {
183+
@Override
184+
public void addHeader(String name, String value) {
185+
addHeaderCalls.add(Tuple.of(name, value));
186+
}
187+
188+
@Override
189+
public LowLevelHttpResponse execute() {
190+
return response;
191+
}
192+
};
193+
}
194+
}
195+
}

0 commit comments

Comments
 (0)
Please sign in to comment.