Skip to content

Commit 75b9a9f

Browse files
committed
feat: added blob mounting support for oras Copy functions
Adds MountFrom and OnMounted to CopyGraphOptions. Allows for trying to mount from multiple repositories. Signed-off-by: Kyle M. Tarplee <[email protected]>
1 parent 48f0943 commit 75b9a9f

File tree

4 files changed

+410
-12
lines changed

4 files changed

+410
-12
lines changed

copy.go

+88-1
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ type CopyGraphOptions struct {
105105
// OnCopySkipped will be called when the sub-DAG rooted by the current node
106106
// is skipped.
107107
OnCopySkipped func(ctx context.Context, desc ocispec.Descriptor) error
108+
// MountFrom returns the candidate repositories that desc may be mounted from.
109+
// The OCI references will be tried in turn. If mounting fails on all of them, then it falls back to a copy.
110+
MountFrom func(ctx context.Context, desc ocispec.Descriptor) ([]string, error)
111+
// OnMounted will be invoked when desc is mounted.
112+
OnMounted func(ctx context.Context, desc ocispec.Descriptor) error
108113
// FindSuccessors finds the successors of the current node.
109114
// fetcher provides cached access to the source storage, and is suitable
110115
// for fetching non-leaf nodes like manifests. Since anything fetched from
@@ -259,12 +264,94 @@ func copyGraph(ctx context.Context, src content.ReadOnlyStorage, dst content.Sto
259264
if exists {
260265
return copyNode(ctx, proxy.Cache, dst, desc, opts)
261266
}
262-
return copyNode(ctx, src, dst, desc, opts)
267+
return mountOrCopyNode(ctx, src, dst, desc, opts)
263268
}
264269

265270
return syncutil.Go(ctx, limiter, fn, root)
266271
}
267272

273+
// mountOrCopyNode enabled cross repository blob mounting.
274+
// sourceReference is the repository to use for mounting (the mount point).
275+
// mounter is the destination for the mount (a well-known implementation of this is *registry.Repository representing the target).
276+
// OnMounted is called (if provided) when the blob is mounted.
277+
// The original PreCopy hook is called only on copy, and therefore not when the blob is mounted.
278+
func mountOrCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor, opts CopyGraphOptions) error {
279+
mounter, ok := dst.(registry.Mounter)
280+
if !ok {
281+
// mounting is not supported by the destination
282+
return copyNode(ctx, src, dst, desc, opts)
283+
}
284+
285+
// Only care to mount blobs
286+
if descriptor.IsManifest(desc) {
287+
return copyNode(ctx, src, dst, desc, opts)
288+
}
289+
290+
if opts.MountFrom == nil {
291+
return copyNode(ctx, src, dst, desc, opts)
292+
}
293+
294+
sourceRepositories, err := opts.MountFrom(ctx, desc)
295+
if err != nil {
296+
// Technically this error is not fatal, we can still attempt to copy the node
297+
// But for consistency with the other callbacks we bail out.
298+
return err
299+
}
300+
301+
if len(sourceRepositories) == 0 {
302+
return copyNode(ctx, src, dst, desc, opts)
303+
}
304+
305+
skipContent := errors.New("skip content")
306+
for i, sourceRepository := range sourceRepositories {
307+
// try mounting this source repository
308+
var mountFailed bool
309+
getContent := func() (io.ReadCloser, error) {
310+
// the invocation of getContent indicates that mounting has failed
311+
mountFailed = true
312+
313+
if len(sourceRepositories)-1 == i {
314+
// this is the last iteration so we need to actually get the content and do the copy
315+
316+
// call the original PreCopy function if it exists
317+
if opts.PreCopy != nil {
318+
if err := opts.PreCopy(ctx, desc); err != nil {
319+
return nil, err
320+
}
321+
}
322+
return src.Fetch(ctx, desc)
323+
}
324+
325+
// We want to return an error that we will test for from mounter.Mount()
326+
return nil, skipContent
327+
}
328+
329+
// Mount or copy
330+
if err := mounter.Mount(ctx, desc, sourceRepository, getContent); err != nil && !errors.Is(err, skipContent) {
331+
return err
332+
}
333+
334+
if !mountFailed {
335+
// mounted, success
336+
if opts.OnMounted != nil {
337+
if err := opts.OnMounted(ctx, desc); err != nil {
338+
return err
339+
}
340+
}
341+
return nil
342+
}
343+
}
344+
345+
// we copied it
346+
if opts.PostCopy != nil {
347+
if err := opts.PostCopy(ctx, desc); err != nil {
348+
return err
349+
}
350+
}
351+
352+
return nil
353+
}
354+
268355
// doCopyNode copies a single content from the source CAS to the destination CAS.
269356
func doCopyNode(ctx context.Context, src content.ReadOnlyStorage, dst content.Storage, desc ocispec.Descriptor) error {
270357
rc, err := src.Fetch(ctx, desc)

copy_test.go

+251-1
Original file line numberDiff line numberDiff line change
@@ -1471,11 +1471,251 @@ func TestCopyGraph_WithOptions(t *testing.T) {
14711471
t.Errorf("count(Push()) = %d, want %d", got, expected)
14721472
}
14731473
})
1474+
1475+
t.Run("MountFrom_Mounted", func(t *testing.T) {
1476+
root = descs[6]
1477+
dst := &countingStorage{storage: cas.NewMemory()}
1478+
var numMount atomic.Int64
1479+
dst.mount = func(ctx context.Context,
1480+
desc ocispec.Descriptor,
1481+
fromRepo string,
1482+
getContent func() (io.ReadCloser, error),
1483+
) error {
1484+
numMount.Add(1)
1485+
if expected := "source"; fromRepo != expected {
1486+
t.Fatalf("fromRepo = %v, want %v", fromRepo, expected)
1487+
}
1488+
rc, err := src.Fetch(ctx, desc)
1489+
if err != nil {
1490+
t.Fatalf("Failed to fetch content: %v", err)
1491+
}
1492+
defer rc.Close()
1493+
err = dst.storage.Push(ctx, desc, rc) // bypass the counters
1494+
if err != nil {
1495+
t.Fatalf("Failed to push content: %v", err)
1496+
}
1497+
return nil
1498+
}
1499+
opts = oras.CopyGraphOptions{}
1500+
var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64
1501+
opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1502+
numPreCopy.Add(1)
1503+
return nil
1504+
}
1505+
opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1506+
numPostCopy.Add(1)
1507+
return nil
1508+
}
1509+
opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error {
1510+
numOnMounted.Add(1)
1511+
return nil
1512+
}
1513+
opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
1514+
numMountFrom.Add(1)
1515+
return []string{"source"}, nil
1516+
}
1517+
if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil {
1518+
t.Fatalf("CopyGraph() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit)
1519+
}
1520+
1521+
if got, expected := dst.numExists.Load(), int64(7); got != expected {
1522+
t.Errorf("count(Exists()) = %d, want %d", got, expected)
1523+
}
1524+
if got, expected := dst.numFetch.Load(), int64(0); got != expected {
1525+
t.Errorf("count(Fetch()) = %d, want %d", got, expected)
1526+
}
1527+
// 7 (exists) - 1 (skipped) = 6 pushes expected
1528+
if got, expected := dst.numPush.Load(), int64(3); got != expected {
1529+
// If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do.
1530+
t.Errorf("count(Push()) = %d, want %d", got, expected)
1531+
}
1532+
if got, expected := numMount.Load(), int64(4); got != expected {
1533+
t.Errorf("count(Mount()) = %d, want %d", got, expected)
1534+
}
1535+
if got, expected := numOnMounted.Load(), int64(4); got != expected {
1536+
t.Errorf("count(OnMounted()) = %d, want %d", got, expected)
1537+
}
1538+
if got, expected := numMountFrom.Load(), int64(4); got != expected {
1539+
t.Errorf("count(MountFrom()) = %d, want %d", got, expected)
1540+
}
1541+
if got, expected := numPreCopy.Load(), int64(3); got != expected {
1542+
t.Errorf("count(PreCopy()) = %d, want %d", got, expected)
1543+
}
1544+
if got, expected := numPostCopy.Load(), int64(3); got != expected {
1545+
t.Errorf("count(PostCopy()) = %d, want %d", got, expected)
1546+
}
1547+
})
1548+
1549+
t.Run("MountFrom_Copied", func(t *testing.T) {
1550+
root = descs[6]
1551+
dst := &countingStorage{storage: cas.NewMemory()}
1552+
var numMount atomic.Int64
1553+
dst.mount = func(ctx context.Context,
1554+
desc ocispec.Descriptor,
1555+
fromRepo string,
1556+
getContent func() (io.ReadCloser, error),
1557+
) error {
1558+
numMount.Add(1)
1559+
if expected := "source"; fromRepo != expected {
1560+
t.Fatalf("fromRepo = %v, want %v", fromRepo, expected)
1561+
}
1562+
1563+
rc, err := getContent()
1564+
if err != nil {
1565+
t.Fatalf("Failed to fetch content: %v", err)
1566+
}
1567+
defer rc.Close()
1568+
err = dst.storage.Push(ctx, desc, rc) // bypass the counters
1569+
if err != nil {
1570+
t.Fatalf("Failed to push content: %v", err)
1571+
}
1572+
return nil
1573+
}
1574+
opts = oras.CopyGraphOptions{}
1575+
var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64
1576+
opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1577+
numPreCopy.Add(1)
1578+
return nil
1579+
}
1580+
opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1581+
numPostCopy.Add(1)
1582+
return nil
1583+
}
1584+
opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error {
1585+
numOnMounted.Add(1)
1586+
return nil
1587+
}
1588+
opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
1589+
numMountFrom.Add(1)
1590+
return []string{"source"}, nil
1591+
}
1592+
if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil {
1593+
t.Fatalf("CopyGraph() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit)
1594+
}
1595+
1596+
if got, expected := dst.numExists.Load(), int64(7); got != expected {
1597+
t.Errorf("count(Exists()) = %d, want %d", got, expected)
1598+
}
1599+
if got, expected := dst.numFetch.Load(), int64(0); got != expected {
1600+
t.Errorf("count(Fetch()) = %d, want %d", got, expected)
1601+
}
1602+
// 7 (exists) - 1 (skipped) = 6 pushes expected
1603+
if got, expected := dst.numPush.Load(), int64(3); got != expected {
1604+
// If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do.
1605+
t.Errorf("count(Push()) = %d, want %d", got, expected)
1606+
}
1607+
if got, expected := numMount.Load(), int64(4); got != expected {
1608+
t.Errorf("count(Mount()) = %d, want %d", got, expected)
1609+
}
1610+
if got, expected := numOnMounted.Load(), int64(0); got != expected {
1611+
t.Errorf("count(OnMounted()) = %d, want %d", got, expected)
1612+
}
1613+
if got, expected := numMountFrom.Load(), int64(4); got != expected {
1614+
t.Errorf("count(MountFrom()) = %d, want %d", got, expected)
1615+
}
1616+
if got, expected := numPreCopy.Load(), int64(7); got != expected {
1617+
t.Errorf("count(PreCopy()) = %d, want %d", got, expected)
1618+
}
1619+
if got, expected := numPostCopy.Load(), int64(7); got != expected {
1620+
t.Errorf("count(PostCopy()) = %d, want %d", got, expected)
1621+
}
1622+
})
1623+
1624+
t.Run("MountFrom_Mounted_Second_Try", func(t *testing.T) {
1625+
root = descs[6]
1626+
dst := &countingStorage{storage: cas.NewMemory()}
1627+
var numMount atomic.Int64
1628+
dst.mount = func(ctx context.Context,
1629+
desc ocispec.Descriptor,
1630+
fromRepo string,
1631+
getContent func() (io.ReadCloser, error),
1632+
) error {
1633+
numMount.Add(1)
1634+
switch fromRepo {
1635+
case "source":
1636+
rc, err := src.Fetch(ctx, desc)
1637+
if err != nil {
1638+
t.Fatalf("Failed to fetch content: %v", err)
1639+
}
1640+
defer rc.Close()
1641+
err = dst.storage.Push(ctx, desc, rc) // bypass the counters
1642+
if err != nil {
1643+
t.Fatalf("Failed to push content: %v", err)
1644+
}
1645+
return nil
1646+
case "missing/the/data":
1647+
// simulate a registry mount will fail, so it will request the content to start the copy.
1648+
rc, err := getContent()
1649+
if err != nil {
1650+
return fmt.Errorf("getContent failed: %w", err)
1651+
}
1652+
defer rc.Close()
1653+
err = dst.storage.Push(ctx, desc, rc) // bypass the counters
1654+
if err != nil {
1655+
t.Fatalf("Failed to push content: %v", err)
1656+
}
1657+
return nil
1658+
default:
1659+
t.Fatalf("fromRepo = %v, want either %v or %v", fromRepo, "missing/the/data", "source")
1660+
return nil
1661+
}
1662+
}
1663+
opts = oras.CopyGraphOptions{}
1664+
var numPreCopy, numPostCopy, numOnMounted, numMountFrom atomic.Int64
1665+
opts.PreCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1666+
numPreCopy.Add(1)
1667+
return nil
1668+
}
1669+
opts.PostCopy = func(ctx context.Context, desc ocispec.Descriptor) error {
1670+
numPostCopy.Add(1)
1671+
return nil
1672+
}
1673+
opts.OnMounted = func(ctx context.Context, d ocispec.Descriptor) error {
1674+
numOnMounted.Add(1)
1675+
return nil
1676+
}
1677+
opts.MountFrom = func(ctx context.Context, desc ocispec.Descriptor) ([]string, error) {
1678+
numMountFrom.Add(1)
1679+
return []string{"missing/the/data", "source"}, nil
1680+
}
1681+
if err := oras.CopyGraph(ctx, src, dst, root, opts); err != nil {
1682+
t.Fatalf("CopyGraph() error = %v, wantErr %v", err, errdef.ErrSizeExceedsLimit)
1683+
}
1684+
1685+
if got, expected := dst.numExists.Load(), int64(7); got != expected {
1686+
t.Errorf("count(Exists()) = %d, want %d", got, expected)
1687+
}
1688+
if got, expected := dst.numFetch.Load(), int64(0); got != expected {
1689+
t.Errorf("count(Fetch()) = %d, want %d", got, expected)
1690+
}
1691+
// 7 (exists) - 1 (skipped) = 6 pushes expected
1692+
if got, expected := dst.numPush.Load(), int64(3); got != expected {
1693+
// If we get >=7 then ErrSkipDesc did not short circuit the push like it is supposed to do.
1694+
t.Errorf("count(Push()) = %d, want %d", got, expected)
1695+
}
1696+
if got, expected := numMount.Load(), int64(4*2); got != expected {
1697+
t.Errorf("count(Mount()) = %d, want %d", got, expected)
1698+
}
1699+
if got, expected := numOnMounted.Load(), int64(4); got != expected {
1700+
t.Errorf("count(OnMounted()) = %d, want %d", got, expected)
1701+
}
1702+
if got, expected := numMountFrom.Load(), int64(4); got != expected {
1703+
t.Errorf("count(MountFrom()) = %d, want %d", got, expected)
1704+
}
1705+
if got, expected := numPreCopy.Load(), int64(3); got != expected {
1706+
t.Errorf("count(PreCopy()) = %d, want %d", got, expected)
1707+
}
1708+
if got, expected := numPostCopy.Load(), int64(3); got != expected {
1709+
t.Errorf("count(PostCopy()) = %d, want %d", got, expected)
1710+
}
1711+
})
14741712
}
14751713

14761714
// countingStorage counts the calls to its content.Storage methods
14771715
type countingStorage struct {
1478-
storage content.Storage
1716+
storage content.Storage
1717+
mount mountFunc
1718+
14791719
numExists, numFetch, numPush atomic.Int64
14801720
}
14811721

@@ -1494,6 +1734,16 @@ func (cs *countingStorage) Push(ctx context.Context, target ocispec.Descriptor,
14941734
return cs.storage.Push(ctx, target, r)
14951735
}
14961736

1737+
type mountFunc func(context.Context, ocispec.Descriptor, string, func() (io.ReadCloser, error)) error
1738+
1739+
func (cs *countingStorage) Mount(ctx context.Context,
1740+
desc ocispec.Descriptor,
1741+
fromRepo string,
1742+
getContent func() (io.ReadCloser, error),
1743+
) error {
1744+
return cs.mount(ctx, desc, fromRepo, getContent)
1745+
}
1746+
14971747
func TestCopyGraph_WithConcurrencyLimit(t *testing.T) {
14981748
src := cas.NewMemory()
14991749
// generate test content

0 commit comments

Comments
 (0)