Skip to content

Commit 756891c

Browse files
authored
Cache hit with service (#632)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent a3503ab commit 756891c

7 files changed

Lines changed: 73 additions & 31 deletions

File tree

src/main/java/land/oras/Registry.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -906,7 +906,8 @@ private void handleError(HttpClient.ResponseWrapper<?> responseWrapper) {
906906
LOG.debug("Response: {}", responseWrapper.response());
907907
throw new OrasException((HttpClient.ResponseWrapper<String>) responseWrapper);
908908
}
909-
throw new OrasException(new HttpClient.ResponseWrapper<>("", responseWrapper.statusCode(), Map.of()));
909+
throw new OrasException(new HttpClient.ResponseWrapper<>(
910+
"", responseWrapper.statusCode(), Map.of(), responseWrapper.service()));
910911
}
911912
}
912913

@@ -917,6 +918,7 @@ private void handleError(HttpClient.ResponseWrapper<?> responseWrapper) {
917918
private void logResponse(HttpClient.ResponseWrapper<?> response) {
918919
LOG.debug("Status Code: {}", response.statusCode());
919920
LOG.debug("Headers: {}", response.headers());
921+
LOG.debug("Service: {}", response.service());
920922
String contentType = response.headers().get(Const.CONTENT_TYPE_HEADER.toLowerCase());
921923
boolean isBinaryResponse = contentType != null && contentType.contains("octet-stream");
922924
// Only log non-binary responses

src/main/java/land/oras/auth/HttpClient.java

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -438,7 +438,8 @@ public <T> TokenResponse refreshToken(
438438
: entry.getValue())));
439439

440440
// Put in the cache
441-
TokenResponse token = JsonUtils.fromJson(responseWrapper.response(), TokenResponse.class);
441+
TokenResponse token = JsonUtils.fromJson(responseWrapper.response(), TokenResponse.class)
442+
.forService(service);
442443
TokenCache.put(newScopes, token);
443444
return token;
444445
}
@@ -501,7 +502,7 @@ private <T> ResponseWrapper<T> executeRequest(
501502
if (cachedToken == null) {
502503
LOG.trace("No cached token found for scopes: {}", newScopes);
503504
} else {
504-
LOG.trace("Cached token for scopes: {}", newScopes);
505+
LOG.trace("Found cached token for scopes: {}", newScopes.withService(cachedToken.service()));
505506
}
506507

507508
// Add authentication header if any (from provider or cached token)
@@ -568,22 +569,17 @@ private <T> ResponseWrapper<T> redoRequest(
568569
AuthProvider authProvider) {
569570
if ((response.statusCode() == 401 || response.statusCode() == 403)) {
570571
LOG.debug("Requesting new token...");
571-
HttpClient.TokenResponse token = refreshToken(toResponseWrapper(response), scopes, authProvider);
572+
HttpClient.TokenResponse token =
573+
refreshToken(toResponseWrapper(response, scopes.getService()), scopes, authProvider);
572574
if (token.issued_at() != null && token.expires_in() != null) {
573575
LOG.debug(
574-
"Found token issued_at {}, expire_id {} and expiring at {} ",
576+
"Received token issued_at {}, expire_id {} and expiring at {} ",
575577
token.issued_at(),
576578
token.expires_in(),
577579
token.issued_at().plusSeconds(token.expires_in()));
578580
}
579-
String bearerToken = token.token();
580-
if (bearerToken == null) {
581-
// Docker registry auth spec allows either token or auth_token (or both if they are the same)
582-
bearerToken = token.access_token();
583-
}
584-
if (bearerToken == null) {
585-
throw new OrasException("No Bearer token received");
586-
}
581+
String bearerToken = token.getEffectiveToken();
582+
String service = token.service();
587583
try {
588584
builder = builder.setHeader(Const.AUTHORIZATION_HEADER, "Bearer " + bearerToken);
589585
HttpResponse<T> newResponse = client.send(builder.build(), handler);
@@ -601,25 +597,26 @@ private <T> ResponseWrapper<T> redoRequest(
601597
}
602598

603599
return toResponseWrapper(
604-
client.send(builder.uri(URI.create(location)).build(), handler));
600+
client.send(builder.uri(URI.create(location)).build(), handler), service);
605601
}
606-
return toResponseWrapper(newResponse);
602+
return toResponseWrapper(newResponse, service);
607603

608604
} catch (Exception e) {
609605
LOG.error("Failed to redo request", e);
610606
throw new OrasException("Unable to redo HTTP request", e);
611607
}
612608
}
613-
return toResponseWrapper(response);
609+
return toResponseWrapper(response, scopes.getService());
614610
}
615611

616-
private <T> ResponseWrapper<T> toResponseWrapper(HttpResponse<T> response) {
612+
private <T> ResponseWrapper<T> toResponseWrapper(HttpResponse<T> response, @Nullable String service) {
617613
return new ResponseWrapper<>(
618614
response.body(),
619615
response.statusCode(),
620616
response.headers().map().entrySet().stream()
621617
.collect(Collectors.toMap(
622-
Map.Entry::getKey, e -> e.getValue().get(0))));
618+
Map.Entry::getKey, e -> e.getValue().get(0))),
619+
service);
623620
}
624621

625622
/**
@@ -649,8 +646,10 @@ private void logRequest(HttpRequest request, byte[] body) {
649646
* @param response The response
650647
* @param statusCode The status code
651648
* @param headers The headers
649+
* @param service The service (not on response but on HTTP headers)
652650
*/
653-
public record ResponseWrapper<T>(T response, int statusCode, Map<String, String> headers) {}
651+
public record ResponseWrapper<T>(
652+
T response, int statusCode, Map<String, String> headers, @Nullable String service) {}
654653

655654
/**
656655
* Insecure trust manager when skipping TLS verification
@@ -697,6 +696,16 @@ public record TokenResponse(
697696
@Nullable ZonedDateTime issued_at) {
698697

699698
/**
699+
* Create a new token response with the service field set
700+
* @param service The service
701+
* @return A new token response with the service field set
702+
*/
703+
public TokenResponse forService(String service) {
704+
return new TokenResponse(token, access_token, service, expires_in, issued_at);
705+
}
706+
707+
/**
708+
* >>>>>>> 6379975 (Store token into caffeine cache (#631))
700709
* Get the effective token
701710
* @return The effective token, which is either the access_token or the token field depending on which one is present
702711
*/

src/main/java/land/oras/auth/Scopes.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ public Scopes withNewScope(String scope) {
136136
* @param service The service to set
137137
* @return A new Scopes object with the given service
138138
*/
139-
public Scopes withService(String service) {
139+
public Scopes withService(@Nullable String service) {
140140
return new Scopes(containerRef, service, scopes);
141141
}
142142

src/main/java/land/oras/auth/TokenCache.java

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
@NullMarked
3636
public final class TokenCache {
3737

38+
/**
39+
* Hard cache limit
40+
*/
41+
public static final int MAX_CACHE_SIZE = 500;
42+
3843
/**
3944
* Logger for this class
4045
*/
@@ -47,11 +52,17 @@ private TokenCache() {
4752
// Private constructor to prevent instantiation
4853
}
4954

55+
/**
56+
* Cache for storing service information based on the service URL. This is used to avoid redundant
57+
*/
58+
private static final Cache<String, String> SERVICE_CACHE =
59+
Caffeine.newBuilder().maximumSize(MAX_CACHE_SIZE).build();
60+
5061
/**
5162
* The cache
5263
*/
5364
private static final Cache<Scopes, HttpClient.TokenResponse> CACHE = Caffeine.newBuilder()
54-
.maximumSize(500)
65+
.maximumSize(MAX_CACHE_SIZE)
5566
.expireAfter(new Expiry<Scopes, HttpClient.TokenResponse>() {
5667
@Override
5768
public long expireAfterCreate(Scopes key, HttpClient.TokenResponse token, long currentTime) {
@@ -80,6 +91,10 @@ public long expireAfterRead(
8091
public static void put(Scopes scopes, HttpClient.TokenResponse token) {
8192
LOG.trace("Caching token for scopes: {}", scopes);
8293
CACHE.put(scopes, token);
94+
if (scopes.getService() != null) {
95+
LOG.trace("Caching service for registry: {}", scopes.getRegistry());
96+
SERVICE_CACHE.put(scopes.getRegistry(), scopes.getService());
97+
}
8398
}
8499

85100
/**
@@ -88,7 +103,16 @@ public static void put(Scopes scopes, HttpClient.TokenResponse token) {
88103
* @return the token response associated with the scopes, or null if not found or expired
89104
*/
90105
public static HttpClient.@Nullable TokenResponse get(Scopes scopes) {
91-
return CACHE.getIfPresent(scopes);
106+
HttpClient.TokenResponse token = CACHE.getIfPresent(scopes);
107+
if (token == null) {
108+
// Lookup for specific service
109+
String service = SERVICE_CACHE.getIfPresent(scopes.getRegistry());
110+
if (service == null) {
111+
return null;
112+
}
113+
return CACHE.getIfPresent(scopes.withService(service));
114+
}
115+
return token;
92116
}
93117

94118
/**

src/test/java/land/oras/DockerIoITCase.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ void shouldPullAnonymousIndexUnqualified() {
9191
void shouldPullOneBlob() {
9292
Registry registry = Registry.builder().build();
9393
ContainerRef containerRef1 = ContainerRef.parse("jbangdev/jbang-action");
94-
Manifest manifest = registry.getManifest(containerRef1);
94+
Index index = registry.getIndex(containerRef1);
9595
String effectiveRegistry = containerRef1.getEffectiveRegistry(registry);
96+
Manifest manifest = registry.getManifest(
97+
containerRef1.withDigest(index.getManifests().get(0).getDigest()));
9698
Layer oneLayer = manifest.getLayers().get(0);
9799
registry.fetchBlob(
98100
containerRef1.forRegistry(effectiveRegistry).withDigest(oneLayer.getDigest()),

src/test/java/land/oras/RegistryWireMockTest.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -541,13 +541,16 @@ void shouldGetToken(WireMockRuntimeInfo wmRuntimeInfo) {
541541
void shouldGetAuthToken(WireMockRuntimeInfo wmRuntimeInfo) {
542542
byte[] blob = tokenScenario(wmRuntimeInfo, "get-auth-token", null, "access-token");
543543
assertEquals("blob-data", new String(blob));
544-
}
544+
blob = tokenScenario(wmRuntimeInfo, "get-auth-token", null, "access-token");
545+
assertEquals("blob-data", new String(blob));
546+
blob = tokenScenario(wmRuntimeInfo, "get-auth-token", null, "access-token");
547+
assertEquals("blob-data", new String(blob));
545548

546-
@Test
547-
void shouldThrowIfNoTokenFound(WireMockRuntimeInfo wmRuntimeInfo) {
548-
assertThrows(OrasException.class, () -> {
549-
tokenScenario(wmRuntimeInfo, "get-auth-token", null, null);
550-
});
549+
// Ensure only one request on token endpoint du to caching
550+
WireMock.verify(
551+
1,
552+
WireMock.getRequestedFor(
553+
WireMock.urlEqualTo("/token?scope=repository:library/get-auth-token:pull&service=localhost")));
551554
}
552555

553556
@Test

src/test/java/land/oras/exception/OrasExceptionTest.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ void shouldWrapResponse() {
5050
HttpClient.ResponseWrapper<String> response = new HttpClient.ResponseWrapper<>(
5151
JsonUtils.toJson(new Error("5001", "foo", "the details")),
5252
500,
53-
Map.of(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE));
53+
Map.of(Const.CONTENT_TYPE_HEADER, Const.DEFAULT_JSON_MEDIA_TYPE),
54+
null);
5455
OrasException orasException = new OrasException(response);
5556

5657
// Getters
@@ -64,7 +65,8 @@ void shouldWrapResponse() {
6465

6566
@Test
6667
void shouldWrapInvalidResponse() {
67-
HttpClient.ResponseWrapper<String> response = new HttpClient.ResponseWrapper<>("corrupted", 500, Map.of());
68+
HttpClient.ResponseWrapper<String> response =
69+
new HttpClient.ResponseWrapper<>("corrupted", 500, Map.of(), null);
6870
OrasException orasException = new OrasException(response);
6971
assertEquals("Response code: 500", orasException.getMessage(), "Message should be correct");
7072
assertNull(orasException.getError(), "Error should be null");

0 commit comments

Comments
 (0)