Skip to content

Commit 847ecb0

Browse files
committed
feat(copy): support mounting existing descriptors from other repositories
1 parent 2445b3d commit 847ecb0

File tree

7 files changed

+341
-32
lines changed

7 files changed

+341
-32
lines changed

src/OrasProject.Oras/Content/MemoryStore.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,19 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14+
using System;
1415
using OrasProject.Oras.Exceptions;
1516
using OrasProject.Oras.Oci;
1617
using System.Collections.Generic;
1718
using System.IO;
19+
using System.Linq;
1820
using System.Threading;
1921
using System.Threading.Tasks;
22+
using OrasProject.Oras.Registry;
2023

2124
namespace OrasProject.Oras.Content;
2225

23-
public class MemoryStore : ITarget, IPredecessorFindable
26+
public class MemoryStore : ITarget, IPredecessorFindable, IMounter
2427
{
2528
private readonly MemoryStorage _storage = new();
2629
private readonly MemoryTagStore _tagResolver = new();
@@ -94,4 +97,16 @@ public async Task TagAsync(Descriptor descriptor, string reference, Cancellation
9497
/// <returns></returns>
9598
public async Task<IEnumerable<Descriptor>> GetPredecessorsAsync(Descriptor node, CancellationToken cancellationToken = default)
9699
=> await _graph.GetPredecessorsAsync(node, cancellationToken).ConfigureAwait(false);
100+
101+
public async Task MountAsync(Descriptor descriptor, string contentReference, Func<CancellationToken, Task<Stream>>? getContents, CancellationToken cancellationToken)
102+
{
103+
var taggedDescriptor = await _tagResolver.ResolveAsync(contentReference, cancellationToken).ConfigureAwait(false);
104+
var successors = await _storage.GetSuccessorsAsync(taggedDescriptor, cancellationToken);
105+
106+
if (descriptor != taggedDescriptor && !successors.Contains(descriptor))
107+
{
108+
await _storage.PushAsync(descriptor, await getContents(cancellationToken), cancellationToken).ConfigureAwait(false);
109+
await _graph.IndexAsync(_storage, descriptor, cancellationToken).ConfigureAwait(false);
110+
}
111+
}
97112
}

src/OrasProject.Oras/Extensions.cs

Lines changed: 94 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,47 @@
1111
// See the License for the specific language governing permissions and
1212
// limitations under the License.
1313

14-
using OrasProject.Oras.Oci;
1514
using System;
15+
using System.IO;
1616
using System.Threading;
1717
using System.Threading.Tasks;
18+
using OrasProject.Oras.Oci;
19+
using OrasProject.Oras.Registry;
1820
using static OrasProject.Oras.Content.Extensions;
1921

2022
namespace OrasProject.Oras;
2123

24+
public struct CopyOptions
25+
{
26+
// public int Concurrency { get; set; }
27+
28+
public event Action<Descriptor> OnPreCopy;
29+
public event Action<Descriptor> OnPostCopy;
30+
public event Action<Descriptor> OnCopySkipped;
31+
public event Action<Descriptor, string> OnMounted;
32+
33+
public Func<Descriptor, string[]> MountFrom { get; set; }
34+
35+
internal void PreCopy(Descriptor descriptor)
36+
{
37+
OnPreCopy?.Invoke(descriptor);
38+
}
39+
40+
internal void PostCopy(Descriptor descriptor)
41+
{
42+
OnPostCopy?.Invoke(descriptor);
43+
}
44+
45+
internal void CopySkipped(Descriptor descriptor)
46+
{
47+
OnCopySkipped?.Invoke(descriptor);
48+
}
49+
50+
internal void Mounted(Descriptor descriptor, string sourceRepository)
51+
{
52+
OnMounted?.Invoke(descriptor, sourceRepository);
53+
}
54+
}
2255
public static class Extensions
2356
{
2457

@@ -36,38 +69,89 @@ public static class Extensions
3669
/// <param name="cancellationToken"></param>
3770
/// <returns></returns>
3871
/// <exception cref="Exception"></exception>
39-
public static async Task<Descriptor> CopyAsync(this ITarget src, string srcRef, ITarget dst, string dstRef, CancellationToken cancellationToken = default)
72+
public static async Task<Descriptor> CopyAsync(this ITarget src, string srcRef, ITarget dst, string dstRef, CancellationToken cancellationToken = default, CopyOptions? copyOptions = default)
4073
{
4174
if (string.IsNullOrEmpty(dstRef))
4275
{
4376
dstRef = srcRef;
4477
}
4578
var root = await src.ResolveAsync(srcRef, cancellationToken).ConfigureAwait(false);
46-
await src.CopyGraphAsync(dst, root, cancellationToken).ConfigureAwait(false);
79+
await src.CopyGraphAsync(dst, root, cancellationToken, copyOptions).ConfigureAwait(false);
4780
await dst.TagAsync(root, dstRef, cancellationToken).ConfigureAwait(false);
4881
return root;
4982
}
5083

51-
public static async Task CopyGraphAsync(this ITarget src, ITarget dst, Descriptor node, CancellationToken cancellationToken)
84+
public static async Task CopyGraphAsync(this ITarget src, ITarget dst, Descriptor node, CancellationToken cancellationToken, CopyOptions? copyOptions = default)
5285
{
5386
// check if node exists in target
5487
if (await dst.ExistsAsync(node, cancellationToken).ConfigureAwait(false))
5588
{
89+
copyOptions?.CopySkipped(node);
5690
return;
5791
}
5892

5993
// retrieve successors
6094
var successors = await src.GetSuccessorsAsync(node, cancellationToken).ConfigureAwait(false);
61-
// obtain data stream
62-
var dataStream = await src.FetchAsync(node, cancellationToken).ConfigureAwait(false);
95+
6396
// check if the node has successors
64-
if (successors != null)
97+
foreach (var childNode in successors)
98+
{
99+
await src.CopyGraphAsync(dst, childNode, cancellationToken, copyOptions).ConfigureAwait(false);
100+
}
101+
102+
var sourceRepositories = copyOptions?.MountFrom(node) ?? [];
103+
if (dst is IMounter mounter && sourceRepositories.Length > 0)
65104
{
66-
foreach (var childNode in successors)
105+
for (var i = 0; i < sourceRepositories.Length; i++)
67106
{
68-
await src.CopyGraphAsync(dst, childNode, cancellationToken).ConfigureAwait(false);
107+
var sourceRepository = sourceRepositories[i];
108+
var mountFailed = false;
109+
110+
async Task<Stream> GetContents(CancellationToken token)
111+
{
112+
// the invocation of getContent indicates that mounting has failed
113+
mountFailed = true;
114+
115+
if (i < sourceRepositories.Length - 1)
116+
{
117+
// If this is not the last one, skip this source and try next one
118+
// We want to return an error that we will test for from mounter.Mount()
119+
throw new SkipSourceException();
120+
}
121+
122+
// this is the last iteration so we need to actually get the content and do the copy
123+
// but first call the PreCopy function
124+
copyOptions?.PreCopy(node);
125+
return await src.FetchAsync(node, token).ConfigureAwait(false);
126+
}
127+
128+
try
129+
{
130+
await mounter.MountAsync(node, sourceRepository, GetContents, cancellationToken).ConfigureAwait(false);
131+
}
132+
catch (SkipSourceException)
133+
{
134+
}
135+
136+
if (!mountFailed)
137+
{
138+
copyOptions?.Mounted(node, sourceRepository);
139+
return;
140+
}
69141
}
70142
}
71-
await dst.PushAsync(node, dataStream, cancellationToken).ConfigureAwait(false);
143+
else
144+
{
145+
// alternatively we just copy it
146+
copyOptions?.PreCopy(node);
147+
var dataStream = await src.FetchAsync(node, cancellationToken).ConfigureAwait(false);
148+
await dst.PushAsync(node, dataStream, cancellationToken).ConfigureAwait(false);
149+
}
150+
151+
// we copied it
152+
copyOptions?.PostCopy(node);
72153
}
154+
155+
private class SkipSourceException : Exception {}
73156
}
157+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System;
2+
using System.IO;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using OrasProject.Oras.Oci;
6+
7+
namespace OrasProject.Oras.Registry;
8+
9+
/// <summary>
10+
/// Mounter allows cross-repository blob mounts.
11+
/// </summary>
12+
public interface IMounter
13+
{
14+
/// <summary>
15+
/// Mount makes the blob with the given descriptor in fromRepo
16+
/// available in the repository signified by the receiver.
17+
/// </summary>
18+
/// <param name="descriptor"></param>
19+
/// <param name="contentReference"></param>
20+
/// <param name="getContents"></param>
21+
/// <param name="cancellationToken"></param>
22+
/// <returns></returns>
23+
Task MountAsync(Descriptor descriptor, string contentReference, Func<CancellationToken, Task<Stream>>? getContents, CancellationToken cancellationToken);
24+
}

src/OrasProject.Oras/Registry/IRepository.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ namespace OrasProject.Oras.Registry;
2727
/// Furthermore, this interface also provides the ability to enforce the
2828
/// separation of the blob and the manifests CASs.
2929
/// </summary>
30-
public interface IRepository : ITarget, IReferenceFetchable, IReferencePushable, IDeletable, ITagListable
30+
public interface IRepository : ITarget, IReferenceFetchable, IReferencePushable, IDeletable, ITagListable, IMounter
3131
{
3232
/// <summary>
3333
/// Blobs provides access to the blob CAS only, which contains config blobs,layers, and other generic blobs.

src/OrasProject.Oras/Registry/Remote/BlobStore.cs

Lines changed: 96 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
namespace OrasProject.Oras.Registry.Remote;
2727

28-
public class BlobStore(Repository repository) : IBlobStore
28+
public class BlobStore(Repository repository) : IBlobStore, IMounter
2929
{
3030
public Repository Repository { get; init; } = repository;
3131

@@ -148,25 +148,7 @@ public async Task PushAsync(Descriptor expected, Stream content, CancellationTok
148148
url = location.IsAbsoluteUri ? location : new Uri(url, location);
149149
}
150150

151-
// monolithic upload
152-
// add digest key to query string with expected digest value
153-
var req = new HttpRequestMessage(HttpMethod.Put, new UriBuilder(url)
154-
{
155-
Query = $"{url.Query}&digest={HttpUtility.UrlEncode(expected.Digest)}"
156-
}.Uri);
157-
req.Content = new StreamContent(content);
158-
req.Content.Headers.ContentLength = expected.Size;
159-
160-
// the expected media type is ignored as in the API doc.
161-
req.Content.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet);
162-
163-
using (var response = await Repository.Options.HttpClient.SendAsync(req, cancellationToken).ConfigureAwait(false))
164-
{
165-
if (response.StatusCode != HttpStatusCode.Created)
166-
{
167-
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
168-
}
169-
}
151+
await InternalPushAsync(url, expected, content, cancellationToken);
170152
}
171153

172154
/// <summary>
@@ -198,4 +180,98 @@ public async Task<Descriptor> ResolveAsync(string reference, CancellationToken c
198180
/// <returns></returns>
199181
public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default)
200182
=> await Repository.DeleteAsync(target, false, cancellationToken).ConfigureAwait(false);
183+
184+
/// <summary>
185+
/// Mounts the given descriptor from contentReference into the blob store.
186+
/// </summary>
187+
/// <param name="descriptor"></param>
188+
/// <param name="contentReference"></param>
189+
/// <param name="getContents"></param>
190+
/// <param name="cancellationToken"></param>
191+
/// <exception cref="HttpRequestException"></exception>
192+
/// <exception cref="Exception"></exception>
193+
public async Task MountAsync(Descriptor descriptor, string contentReference,
194+
Func<CancellationToken, Task<Stream>>? getContents, CancellationToken cancellationToken)
195+
{
196+
var url = new UriFactory(Repository.Options).BuildRepositoryBlobUpload();
197+
var mountReq = new HttpRequestMessage(HttpMethod.Post, new UriBuilder(url)
198+
{
199+
Query =
200+
$"{url.Query}&mount={HttpUtility.UrlEncode(descriptor.Digest)}&from={HttpUtility.UrlEncode(contentReference)}"
201+
}.Uri);
202+
203+
using (var response = await Repository.Options.HttpClient.SendAsync(mountReq, cancellationToken)
204+
.ConfigureAwait(false))
205+
{
206+
switch (response.StatusCode)
207+
{
208+
case HttpStatusCode.Created:
209+
// 201, layer has been mounted
210+
return;
211+
case HttpStatusCode.Accepted:
212+
{
213+
// 202, mounting failed. upload session has begun
214+
var location = response.Headers.Location ??
215+
throw new HttpRequestException("missing location header");
216+
url = location.IsAbsoluteUri ? location : new Uri(url, location);
217+
break;
218+
}
219+
default:
220+
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
221+
}
222+
}
223+
224+
// From the [spec]:
225+
//
226+
// "If a registry does not support cross-repository mounting
227+
// or is unable to mount the requested blob,
228+
// it SHOULD return a 202.
229+
// This indicates that the upload session has begun
230+
// and that the client MAY proceed with the upload."
231+
//
232+
// So we need to get the content from somewhere in order to
233+
// push it. If the caller has provided a getContent function, we
234+
// can use that, otherwise pull the content from the source repository.
235+
//
236+
// [spec]: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#mounting-a-blob-from-another-repository
237+
238+
Stream contents;
239+
if (getContents != null)
240+
{
241+
contents = await getContents(cancellationToken).ConfigureAwait(false);
242+
}
243+
else
244+
{
245+
var referenceOptions = repository.Options with
246+
{
247+
Reference = Reference.Parse(contentReference),
248+
};
249+
contents = await new Repository(referenceOptions).FetchAsync(descriptor, cancellationToken);
250+
}
251+
252+
await InternalPushAsync(url, descriptor, contents, cancellationToken).ConfigureAwait(false);
253+
}
254+
255+
private async Task InternalPushAsync(Uri url, Descriptor descriptor, Stream content,
256+
CancellationToken cancellationToken)
257+
{
258+
// monolithic upload
259+
// add digest key to query string with descriptor digest value
260+
var req = new HttpRequestMessage(HttpMethod.Put, new UriBuilder(url)
261+
{
262+
Query = $"{url.Query}&digest={HttpUtility.UrlEncode(descriptor.Digest)}"
263+
}.Uri);
264+
req.Content = new StreamContent(content);
265+
req.Content.Headers.ContentLength = descriptor.Size;
266+
267+
// the descriptor media type is ignored as in the API doc.
268+
req.Content.Headers.ContentType = new MediaTypeHeaderValue(MediaTypeNames.Application.Octet);
269+
270+
using var response =
271+
await Repository.Options.HttpClient.SendAsync(req, cancellationToken).ConfigureAwait(false);
272+
if (response.StatusCode != HttpStatusCode.Created)
273+
{
274+
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
275+
}
276+
}
201277
}

src/OrasProject.Oras/Registry/Remote/Repository.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,4 +331,22 @@ internal Reference ParseReferenceFromContentReference(string reference)
331331
/// <param name="desc"></param>
332332
/// <returns></returns>
333333
private IBlobStore BlobStore(Descriptor desc) => IsManifest(desc) ? Manifests : Blobs;
334+
335+
/// <summary>
336+
/// Mount makes the blob with the given digest in fromRepo
337+
/// available in the repository signified by the receiver.
338+
///
339+
/// This avoids the need to pull content down from fromRepo only to push it to r.
340+
///
341+
/// If the registry does not implement mounting, getContent will be used to get the
342+
/// content to push. If getContent is null, the content will be pulled from the source
343+
/// repository.
344+
/// </summary>
345+
/// <param name="descriptor"></param>
346+
/// <param name="contentReference"></param>
347+
/// <param name="getContents"></param>
348+
/// <param name="cancellationToken"></param>
349+
/// <returns></returns>
350+
public Task MountAsync(Descriptor descriptor, string contentReference, Func<CancellationToken, Task<Stream>>? getContents, CancellationToken cancellationToken)
351+
=> ((IMounter)Blobs).MountAsync(descriptor,contentReference, getContents, cancellationToken);
334352
}

0 commit comments

Comments
 (0)