Skip to content

Commit 398da85

Browse files
authored
Add support for tar files in OCI Layout in adition of exploded directories (#739)
Signed-off-by: Valentin Delaye <jonesbusy@users.noreply.github.com>
1 parent 8e20441 commit 398da85

5 files changed

Lines changed: 340 additions & 11 deletions

File tree

src/main/java/land/oras/OCILayout.java

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.io.InputStream;
2626
import java.nio.file.Files;
2727
import java.nio.file.Path;
28+
import java.nio.file.StandardCopyOption;
2829
import java.util.HashMap;
2930
import java.util.LinkedList;
3031
import java.util.List;
@@ -33,6 +34,7 @@
3334
import java.util.concurrent.Executors;
3435
import java.util.function.Supplier;
3536
import land.oras.exception.OrasException;
37+
import land.oras.utils.ArchiveUtils;
3638
import land.oras.utils.Const;
3739
import land.oras.utils.JsonUtils;
3840
import land.oras.utils.SupportedAlgorithm;
@@ -54,6 +56,14 @@ public final class OCILayout extends OCI<LayoutRef> {
5456
*/
5557
private Path path;
5658

59+
/**
60+
* When non-null the layout is backed by a tar file.
61+
* {@code path} then points to a temporary directory that holds the extracted contents.
62+
* Every mutating operation re-packs that temporary directory back into this tar file.
63+
*/
64+
@Nullable
65+
private Path tarPath;
66+
5767
/**
5868
* Private constructor
5969
*/
@@ -96,6 +106,7 @@ public boolean mountBlob(LayoutRef sourceRef, LayoutRef targetRef) {
96106
} catch (IOException e) {
97107
throw new OrasException("Failed to mount blob", e);
98108
}
109+
packToTar();
99110
return true;
100111
}
101112

@@ -214,6 +225,7 @@ public Manifest pushManifest(LayoutRef layoutRef, Manifest manifest) {
214225
} catch (IOException e) {
215226
throw new OrasException("Failed to write manifest", e);
216227
}
228+
packToTar();
217229
return manifest;
218230
}
219231

@@ -249,6 +261,7 @@ public Index pushIndex(LayoutRef layoutRef, Index index) {
249261
} catch (IOException e) {
250262
throw new OrasException("Failed to write manifest", e);
251263
}
264+
packToTar();
252265
return index;
253266
}
254267

@@ -330,7 +343,9 @@ public Layer pushBlob(LayoutRef ref, Path blob, Map<String, String> annotations)
330343
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
331344
}
332345
Files.copy(blob, blobPath);
333-
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
346+
Layer layer = Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
347+
packToTar();
348+
return layer;
334349
} catch (IOException e) {
335350
throw new OrasException("Failed to push blob", e);
336351
}
@@ -357,7 +372,9 @@ public Layer pushBlob(LayoutRef ref, long size, Supplier<InputStream> stream, Ma
357372
Files.copy(is, blobPath);
358373
}
359374
ensureDigest(ref, blobPath);
360-
return Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
375+
Layer layer = Layer.fromFile(blobPath, ref.getAlgorithm()).withAnnotations(annotations);
376+
packToTar();
377+
return layer;
361378
} catch (IOException e) {
362379
throw new OrasException("Failed to push blob", e);
363380
}
@@ -406,7 +423,9 @@ public Tags getTags(LayoutRef ref, int n, @Nullable String last) {
406423

407424
@Override
408425
public Repositories getRepositories() {
409-
return new Repositories(List.of(path.getFileName().toString()));
426+
// When tar-backed, report the tar file name rather than the temp-dir name
427+
Path reportPath = tarPath != null ? tarPath : path;
428+
return new Repositories(List.of(reportPath.getFileName().toString()));
410429
}
411430

412431
@Override
@@ -438,6 +457,29 @@ private void setPath(Path path) {
438457
this.path = path;
439458
}
440459

460+
private void setTarPath(Path tarPath) {
461+
this.tarPath = tarPath;
462+
}
463+
464+
/**
465+
* Re-pack the working directory back into the backing tar file.
466+
* Called after every mutating operation when {@link #tarPath} is non-null.
467+
*/
468+
private void packToTar() {
469+
if (tarPath == null) {
470+
return;
471+
}
472+
try {
473+
// Pack without directory-name prefix so entries sit at the root of the tar,
474+
// matching the OCI Image Layout tar format (blobs/, index.json, oci-layout …).
475+
LocalPath packed = ArchiveUtils.tar(LocalPath.of(path), false);
476+
// Atomically replace the backing tar file
477+
Files.move(packed.getPath(), tarPath, StandardCopyOption.REPLACE_EXISTING);
478+
} catch (IOException e) {
479+
throw new OrasException("Failed to repack OCI layout tar: " + tarPath, e);
480+
}
481+
}
482+
441483
/**
442484
* Return the JSON representation of the referrers
443485
* @return The JSON string
@@ -674,14 +716,26 @@ public static OCILayout fromLayoutIndex(Path layoutPath) {
674716
}
675717

676718
/**
677-
* Return the path to the OCI layout
719+
* Return the path to the OCI layout working directory.
720+
* <p>When the layout is tar-backed this is the temporary directory into which the tar
721+
* was extracted; use {@link #getTarPath()} to obtain the path of the backing tar file.</p>
678722
* @return The path to the OCI layout
679723
*/
680724
@JsonIgnore
681725
public Path getPath() {
682726
return path;
683727
}
684728

729+
/**
730+
* Return the path to the backing tar file, or {@code null} if the layout is directory-backed.
731+
* @return The tar file path, or {@code null}
732+
*/
733+
@JsonIgnore
734+
@Nullable
735+
public Path getTarPath() {
736+
return tarPath;
737+
}
738+
685739
/**
686740
* Builder for the registry
687741
*/
@@ -697,12 +751,30 @@ private Builder() {
697751
}
698752

699753
/**
700-
* Return a new builder with default path
701-
* @param path The path
754+
* Return a new builder with default path.
755+
* <p>If {@code path} ends with {@code .tar} the layout is considered to be tar-backed.
756+
* When the tar file already exists it is extracted to a temporary directory; that
757+
* temporary directory is used as the working {@code path} for all operations.
758+
* After every mutating operation the working directory is re-packed into the original
759+
* tar file. If the tar file does not yet exist a fresh, empty layout is created in
760+
* the temporary directory and packed once on the first mutation.</p>
761+
* @param path The path (directory or {@code .tar} file)
702762
* @return The builder
703763
*/
704764
public OCILayout.Builder defaults(Path path) {
705-
layout.setPath(path);
765+
String name = path.getFileName().toString();
766+
if (name.endsWith(".tar")) {
767+
// Tar-backed layout: work in a temp directory
768+
Path workDir = ArchiveUtils.createTempDir();
769+
if (Files.exists(path)) {
770+
// Extract existing tar into the temp working directory
771+
ArchiveUtils.untar(path, workDir);
772+
}
773+
layout.setPath(workDir);
774+
layout.setTarPath(path);
775+
} else {
776+
layout.setPath(path);
777+
}
706778
return this;
707779
}
708780

@@ -727,6 +799,7 @@ public OCILayout build() {
727799
}
728800
}
729801
layout.ensureMinimalLayout();
802+
layout.packToTar();
730803
return layout;
731804
}
732805
}

src/main/java/land/oras/utils/ArchiveUtils.java

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,28 @@ public static LocalPath zip(LocalPath sourceDir) {
165165
}
166166

167167
/**
168-
* Create a tar.gz file from a directory
168+
* Create a tar file from a directory, prefixing every entry with the source directory's own name.
169+
* <p>Equivalent to calling {@link #tar(LocalPath, boolean) tar(sourceDir, true)}.</p>
169170
* @param sourceDir The source directory
170-
* @return The path to the tar.gz file
171+
* @return The local path to the temporary tar file
171172
*/
172173
public static LocalPath tar(LocalPath sourceDir) {
174+
return tar(sourceDir, true);
175+
}
176+
177+
/**
178+
* Create a tar file from a directory.
179+
* <p>When {@code includeDirectoryName} is {@code true} (the default behaviour) every entry
180+
* is prefixed with the source directory's own name, e.g. {@code mydir/blobs/sha256/...}.
181+
* When {@code false}, entries are stored relative to the source directory itself, e.g.
182+
* {@code blobs/sha256/...} — which is the format required by the OCI Image Layout
183+
* specification when packaging a layout as a plain tar archive.</p>
184+
* @param sourceDir The source directory
185+
* @param includeDirectoryName {@code true} to prefix entries with the directory name,
186+
* {@code false} for root-relative entry names
187+
* @return The local path to the temporary tar file
188+
*/
189+
public static LocalPath tar(LocalPath sourceDir, boolean includeDirectoryName) {
173190
Path tarFile = createTempTar();
174191
boolean isAbsolute = sourceDir.getPath().isAbsolute();
175192
try (OutputStream fos = Files.newOutputStream(tarFile);
@@ -183,8 +200,13 @@ public static LocalPath tar(LocalPath sourceDir) {
183200
paths.forEach(path -> {
184201
LOG.trace("Visiting path: {}", path);
185202
try {
186-
Path baseName = isAbsolute ? sourceDir.getPath().getFileName() : sourceDir.getPath();
187-
Path relativePath = baseName.resolve(sourceDir.getPath().relativize(path));
203+
Path relativePath;
204+
if (includeDirectoryName) {
205+
Path baseName = isAbsolute ? sourceDir.getPath().getFileName() : sourceDir.getPath();
206+
relativePath = baseName.resolve(sourceDir.getPath().relativize(path));
207+
} else {
208+
relativePath = sourceDir.getPath().relativize(path);
209+
}
188210
if (relativePath.toString().isEmpty()) {
189211
LOG.trace("Skipping root directory: {}", path);
190212
return;

0 commit comments

Comments
 (0)