Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/java/land/oras/ContainerRef.java
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,15 @@ public String getBlobsUploadDigestPath(Registry registry) {
return "%s/blobs/uploads/?digest=%s".formatted(getApiPrefix(registry), digest);
}

/**
* Return the blobs upload URL for POST upload to get the upload location
* @param registry The registry
* @return The blobs upload URL
*/
public String getBlobsUploadPath(Registry registry) {
return "%s/blobs/uploads/".formatted(getApiPrefix(registry));
}

/**
* Return the blobs URL
* @param registry The registry
Expand Down
13 changes: 9 additions & 4 deletions src/main/java/land/oras/CopyUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
import land.oras.exception.OrasException;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
Expand Down Expand Up @@ -73,10 +74,14 @@ void copy(

// Write all layer
for (Layer layer : source.collectLayers(sourceRef, contentType, true)) {
try (InputStream is = source.fetchBlob(sourceRef.withDigest(layer.getDigest()))) {
target.pushBlob(targetRef.withDigest(layer.getDigest()), is);
LOG.debug("Copied layer {}", layer.getDigest());
}
Objects.requireNonNull(layer.getDigest(), "Layer digest is required for streaming copy");
Objects.requireNonNull(layer.getSize(), "Layer size is required for streaming copy");
target.pushBlob(
targetRef.withDigest(layer.getDigest()),
layer.getSize(),
() -> source.fetchBlob(sourceRef.withDigest(layer.getDigest())),
layer.getAnnotations());
LOG.debug("Copied layer {}", layer.getDigest());
}

// Single manifest
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/land/oras/OCI.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import land.oras.exception.OrasException;
import land.oras.utils.ArchiveUtils;
import land.oras.utils.Const;
Expand Down Expand Up @@ -375,6 +376,16 @@ public abstract Manifest pushArtifact(
*/
public abstract Layer pushBlob(T ref, Path blob, Map<String, String> annotations);

/**
* Push a blob from input stream with known digest and size
* @param ref The container ref with digest
* @param size The size of the blob
* @param stream The input stream of the blob
* @param annotations The annotations
* @return The layer
*/
public abstract Layer pushBlob(T ref, long size, Supplier<InputStream> stream, Map<String, String> annotations);

/**
* Push the blob for the given layer
* @param ref The container ref
Expand Down
28 changes: 28 additions & 0 deletions src/main/java/land/oras/OCILayout.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import land.oras.exception.OrasException;
import land.oras.utils.Const;
import land.oras.utils.JsonUtils;
Expand Down Expand Up @@ -280,6 +281,33 @@ public Layer pushBlob(LayoutRef ref, Path blob, Map<String, String> annotations)
}
}

@Override
public Layer pushBlob(LayoutRef ref, long size, Supplier<InputStream> stream, Map<String, String> annotations) {
String digest = ref.getTag();
if (digest == null) {
throw new OrasException("Digest is required to push blob to layout");
}
boolean isDigest = SupportedAlgorithm.isSupported(digest);
if (!isDigest) {
throw new OrasException("Unsupported digest: %s".formatted(digest));
}
ensureAlgorithmPath(digest);
try {
Path blobPath = getBlobPath(ref);
if (Files.exists(blobPath)) {
LOG.info("Blob already exists: {}", digest);
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
}
try (InputStream is = stream.get()) {
Files.copy(is, blobPath);
}
ensureDigest(ref, blobPath);
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
} catch (IOException e) {
throw new OrasException("Failed to push blob", e);
}
}

@Override
public Layer pushBlob(LayoutRef ref, byte[] data) {
try {
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/land/oras/Registry.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import land.oras.auth.AuthProvider;
import land.oras.auth.AuthStoreAuthenticationProvider;
import land.oras.auth.HttpClient;
Expand Down Expand Up @@ -467,6 +468,58 @@ public Layer pushBlob(ContainerRef containerRef, Path blob, Map<String, String>
return Layer.fromFile(blob, containerRef.getAlgorithm()).withAnnotations(annotations);
}

@Override
public Layer pushBlob(ContainerRef ref, long size, Supplier<InputStream> stream, Map<String, String> annotations) {
String digest = ref.getDigest();
if (digest == null) {
throw new OrasException("Digest is required to push blob with stream");
}
ContainerRef containerRef = ref.forRegistry(this).checkBlocked(this);
if (containerRef.isInsecure(this) && !this.isInsecure()) {
return asInsecure().pushBlob(ref, size, stream, annotations);
}
if (hasBlob(containerRef)) {
LOG.info("Blob already exists: {}", digest);
return Layer.fromDigest(digest, size).withAnnotations(annotations);
}
// Empty post without digest
URI uri = URI.create("%s://%s".formatted(getScheme(), containerRef.getBlobsUploadPath(this)));
HttpClient.ResponseWrapper<String> response = client.post(
uri,
new byte[0],
Map.of(Const.CONTENT_TYPE_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE),
Scopes.of(this, containerRef),
authProvider);
logResponse(response);
if (response.statusCode() != 202) {
throw new OrasException("Failed to initiate blob upload: %s".formatted(response.response()));
}
String location = response.headers().get(Const.LOCATION_HEADER.toLowerCase());
// Ensure location is absolute URI
if (!location.startsWith("http") && !location.startsWith("https")) {
location = "%s://%s/%s".formatted(getScheme(), ref.getApiRegistry(this), location.replaceFirst("^/", ""));
}
LOG.debug("Location header: {}", location);

URI uploadURI = createLocationWithDigest(location, digest);

response = client.upload(
uploadURI,
size,
Map.of(Const.CONTENT_TYPE_HEADER, Const.APPLICATION_OCTET_STREAM_HEADER_VALUE),
stream,
Scopes.of(this, containerRef),
authProvider);
logResponse(response);
if (response.statusCode() == 201) {
LOG.debug("Successful push: {}", response.response());
} else {
throw new OrasException("Failed to push layer: %s".formatted(response.response()));
}
handleError(response);
return Layer.fromDigest(digest, size).withAnnotations(annotations);
}

@Override
public Layer pushBlob(ContainerRef containerRef, byte[] data) {
String digest = containerRef.getAlgorithm().digest(data);
Expand Down
32 changes: 31 additions & 1 deletion src/main/java/land/oras/auth/HttpClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -234,6 +235,35 @@ public ResponseWrapper<String> upload(
}
}

/**
* Upload from an input stream.
* @param uri The URI
* @param size The size of the input stream
* @param headers The headers
* @param stream The input stream
* @param scopes The scopes
* @param authProvider The authentication provider
* @return The response
*/
public ResponseWrapper<String> upload(
URI uri,
long size,
Map<String, String> headers,
Supplier<InputStream> stream,
Scopes scopes,
AuthProvider authProvider) {
return executeRequest(
"PUT",
uri,
true,
headers,
new byte[0],
HttpResponse.BodyHandlers.ofString(),
HttpRequest.BodyPublishers.fromPublisher(HttpRequest.BodyPublishers.ofInputStream(stream), size),
scopes,
authProvider);
}

/**
* Perform a HEAD request
* @param uri The URI
Expand Down Expand Up @@ -296,7 +326,7 @@ public ResponseWrapper<String> post(
headers,
body,
HttpResponse.BodyHandlers.ofString(),
HttpRequest.BodyPublishers.ofByteArray(body),
body.length == 0 ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofByteArray(body),
scopes,
authProvider);
}
Expand Down
4 changes: 4 additions & 0 deletions src/test/java/land/oras/ContainerRefTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
class ContainerRefTest {

@Test
@Execution(ExecutionMode.SAME_THREAD)
void shouldReadRegistriesConfig(@TempDir Path homeDir) throws Exception {
// language=toml
String config =
Expand Down Expand Up @@ -77,6 +78,7 @@ void shouldReadRegistriesConfig(@TempDir Path homeDir) throws Exception {
}

@Test
@Execution(ExecutionMode.SAME_THREAD)
void shouldDetermineFromAlias(@TempDir Path homeDir) throws Exception {

// language=toml
Expand All @@ -99,6 +101,7 @@ void shouldDetermineFromAlias(@TempDir Path homeDir) throws Exception {
}

@Test
@Execution(ExecutionMode.SAME_THREAD)
void shouldRewriteAllSubdomainToLocalProxy(@TempDir Path homeDir) throws Exception {

// language=toml
Expand Down Expand Up @@ -145,6 +148,7 @@ void shouldRewriteAllSubdomainToLocalProxy(@TempDir Path homeDir) throws Excepti
}

@Test
@Execution(ExecutionMode.SAME_THREAD)
void shouldDetermineEffectiveRegistry(@TempDir Path homeDir) throws Exception {

// Use from container ref
Expand Down
8 changes: 3 additions & 5 deletions src/test/java/land/oras/GitHubContainerRegistryITCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,6 @@ class GitHubContainerRegistryITCase {
@TempDir
Path tempDir;

@TempDir
private static Path homeDir;

@Test
void shouldPullIndex() {
Registry registry = Registry.builder().build();
Expand All @@ -48,7 +45,8 @@ void shouldPullIndex() {
}

@Test
void shouldPullIndexWithAlias() throws Exception {
@Execution(ExecutionMode.SAME_THREAD)
void shouldPullIndexWithAlias(@TempDir Path homeDir) throws Exception {
// language=toml
String config =
"""
Expand All @@ -57,7 +55,7 @@ void shouldPullIndexWithAlias() throws Exception {
""";

// Setup
TestUtils.createRegistriesConfFile(tempDir, config);
TestUtils.createRegistriesConfFile(homeDir, config);

TestUtils.withHome(homeDir, () -> {
Registry registry = Registry.builder().defaults().build();
Expand Down
24 changes: 24 additions & 0 deletions src/test/java/land/oras/OCILayoutTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,30 @@ void shouldPushBlob() throws IOException {
assertBlobContent(path, digest, "hi");
}

@Test
void shouldFailToPushBlobViaStreamWithoutDigest() {
Path path = layoutPath.resolve("shouldFailToPushBlobViaStreamWithoutDigest");
byte[] content = "hi".getBytes(StandardCharsets.UTF_8);
OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build();
LayoutRef layoutRef = LayoutRef.parse("test");
OrasException e = assertThrows(OrasException.class, () -> {
ociLayout.pushBlob(layoutRef, content.length, () -> InputStream.nullInputStream(), Map.of());
});
assertEquals("Digest is required to push blob to layout", e.getMessage());
}

@Test
void shouldFailToPushBlobViaStreamWithInvalidDigest() {
Path path = layoutPath.resolve("shouldFailToPushBlobViaStreamWithInvalidDigest");
byte[] content = "hi".getBytes(StandardCharsets.UTF_8);
OCILayout ociLayout = OCILayout.Builder.builder().defaults(path).build();
LayoutRef layoutRef = LayoutRef.parse("test:1234");
OrasException e = assertThrows(OrasException.class, () -> {
ociLayout.pushBlob(layoutRef, content.length, () -> InputStream.nullInputStream(), Map.of());
});
assertEquals("Unsupported digest: 1234", e.getMessage());
}

@Test
void cannotPushBlobWithoutTagOrDigest() throws IOException {

Expand Down
2 changes: 1 addition & 1 deletion src/test/java/land/oras/PublicECRITCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import org.junit.jupiter.api.parallel.ExecutionMode;
import uk.org.webcompere.systemstubs.environment.EnvironmentVariables;

@Execution(ExecutionMode.SAME_THREAD) // Avoid 429 Too Many Requests for unauthenticated requests to public ECR
@Execution(ExecutionMode.SAME_THREAD)
class PublicECRITCase {

@TempDir
Expand Down
Loading