Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(referrers): push manifest with subject #163

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
14 changes: 13 additions & 1 deletion src/OrasProject.Oras/Content/Digest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ internal static string Validate(string? digest)
return digest;
}

internal static string GetAlgorithm(string digest)
{
var validatedDigest = Validate(digest);
return validatedDigest.Split(':')[0];
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
}

internal static string GetRef(string digest)
{
var validatedDigest = Validate(digest);
return validatedDigest.Split(':')[1];
}
pat-pan marked this conversation as resolved.
Show resolved Hide resolved

/// <summary>
/// Generates a SHA-256 digest from a byte array.
/// </summary>
Expand All @@ -59,4 +71,4 @@ internal static string ComputeSHA256(byte[] content)
var output = $"sha256:{BitConverter.ToString(hash).Replace("-", "")}";
return output.ToLower();
}
}
}
37 changes: 37 additions & 0 deletions src/OrasProject.Oras/Exceptions/NoReferrerUpdateException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright The ORAS Authors.
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using System;

namespace OrasProject.Oras.Exceptions;


/// <summary>
/// NoReferrerUpdateException is thrown when no referrer update is needed.
/// </summary>
public class NoReferrerUpdateException : Exception
{
public NoReferrerUpdateException()
{
}

public NoReferrerUpdateException(string message)
: base(message)
{
}

public NoReferrerUpdateException(string message, Exception? innerException)
: base(message, innerException)
{
}
}
13 changes: 13 additions & 0 deletions src/OrasProject.Oras/Oci/Descriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Serialization;
using OrasProject.Oras.Content;

namespace OrasProject.Oras.Oci;

Expand Down Expand Up @@ -70,4 +71,16 @@ public static Descriptor Create(Span<byte> data, string mediaType)
};

internal BasicDescriptor BasicDescriptor => new BasicDescriptor(MediaType, Digest, Size);

internal static bool IsEmptyOrNull(Descriptor? descriptor)
{
return descriptor == null || descriptor.Size == 0 || string.IsNullOrEmpty(descriptor.Digest) || string.IsNullOrEmpty(descriptor.MediaType);
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
}

internal static Descriptor EmptyDescriptor() => new ()
{
MediaType = "",
Digest = "",
Size = 0
};
}
22 changes: 22 additions & 0 deletions src/OrasProject.Oras/Oci/Index.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
// limitations under the License.

using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using OrasProject.Oras.Content;

namespace OrasProject.Oras.Oci;

Expand All @@ -39,4 +42,23 @@ public class Index : Versioned
[JsonPropertyName("annotations")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public IDictionary<string, string>? Annotations { get; set; }

internal static (Descriptor, byte[]) GenerateIndex(IList<Descriptor> manifests)
{
var index = new Index()
{
Manifests = manifests,
MediaType = Oci.MediaType.ImageIndex,
SchemaVersion = 2
};
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
var indexContent = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(index));
var indexDesc = new Descriptor()
{
Digest = Digest.ComputeSHA256(indexContent),
MediaType = Oci.MediaType.ImageIndex,
Size = indexContent.Length
};
pat-pan marked this conversation as resolved.
Show resolved Hide resolved

return (indexDesc, indexContent);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ namespace OrasProject.Oras.Registry.Remote;
internal static class HttpResponseMessageExtensions
{
private const string _dockerContentDigestHeader = "Docker-Content-Digest";

/// <summary>
/// Parses the error returned by the remote registry.
/// </summary>
Expand Down Expand Up @@ -101,6 +100,20 @@ public static void VerifyContentDigest(this HttpResponseMessage response, string
throw new HttpIOException(HttpRequestError.InvalidResponse, $"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response; digest mismatch in Docker-Content-Digest: received {contentDigest} when expecting {digestStr}");
}
}

/// <summary>
/// CheckOciSubjectHeader checks if the response header contains "OCI-Subject",
/// repository ReferrerState is set to supported if it is present
/// </summary>
/// <param name="response"></param>
/// <param name="repository"></param>
public static void CheckOciSubjectHeader(this HttpResponseMessage response, Repository repository)
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
{
if (response.Headers.TryGetValues("OCI-Subject", out var values))
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
{
repository.ReferrerState = Referrers.ReferrerState.ReferrerSupported;
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
}
}

/// <summary>
/// Returns a descriptor generated from the response.
Expand Down Expand Up @@ -160,7 +173,7 @@ public static async Task<Descriptor> GenerateDescriptorAsync(this HttpResponseMe
{
serverDigest = serverHeaderDigest.FirstOrDefault();
if (!string.IsNullOrEmpty(serverDigest))
{
{
response.VerifyContentDigest(serverDigest);
}
}
Expand Down
163 changes: 160 additions & 3 deletions src/OrasProject.Oras/Registry/Remote/ManifestStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,21 @@
using OrasProject.Oras.Exceptions;
using OrasProject.Oras.Oci;
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using OrasProject.Oras.Content;
using Index = OrasProject.Oras.Oci.Index;

namespace OrasProject.Oras.Registry.Remote;

public class ManifestStore(Repository repository) : IManifestStore
{
public Repository Repository { get; init; } = repository;

Check warning on line 31 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View workflow job for this annotation

GitHub Actions / Analyze (8.0.x)

Parameter 'Repository repository' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.

Check warning on line 31 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View workflow job for this annotation

GitHub Actions / Analyze (8.0.x)

Parameter 'Repository repository' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.

Check warning on line 31 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View workflow job for this annotation

GitHub Actions / build (8.0.x)

Parameter 'Repository repository' is captured into the state of the enclosing type and its value is also used to initialize a field, property, or event.

/// <summary>
/// Fetches the content identified by the descriptor.
Expand Down Expand Up @@ -140,7 +144,8 @@
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task PushAsync(Descriptor expected, Stream content, CancellationToken cancellationToken = default)
=> await InternalPushAsync(expected, content, expected.Digest, cancellationToken).ConfigureAwait(false);
=> await PushWithIndexingAsync(expected, content, expected.Digest, cancellationToken).ConfigureAwait(false);


/// <summary>
/// PushReferenceASync pushes the manifest with a reference tag.
Expand All @@ -153,17 +158,168 @@
public async Task PushAsync(Descriptor expected, Stream content, string reference, CancellationToken cancellationToken = default)
{
var contentReference = Repository.ParseReference(reference).ContentReference!;
await InternalPushAsync(expected, content, contentReference, cancellationToken).ConfigureAwait(false);
await PushWithIndexingAsync(expected, content, contentReference, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// PushWithIndexingAsync pushes the given manifest to the repository with indexing support.
/// If referrer support is not enabled, the function will first push the content, then process and update
/// the referrers index before pushing the content again. It handles both image manifests and index manifests.
/// </summary>
/// <param name="expected"></param>
/// <param name="content"></param>
/// <param name="reference"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private async Task PushWithIndexingAsync(Descriptor expected, Stream content, string reference,
CancellationToken cancellationToken = default)
{
switch (expected.MediaType)
{
case MediaType.ImageManifest:
case MediaType.ImageIndex:
if (Repository.ReferrerState == Referrers.ReferrerState.ReferrerSupported)
{
await InternalPushAsync(expected, content, reference, cancellationToken).ConfigureAwait(false);
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
return;
}

var contentBytes = await content.ReadAllAsync(expected, cancellationToken);
using (var contentDuplicate = new MemoryStream(contentBytes))
{
await InternalPushAsync(expected, contentDuplicate, reference, cancellationToken).ConfigureAwait(false);
}
if (Repository.ReferrerState == Referrers.ReferrerState.ReferrerSupported)
{
return;
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
}

using (var contentDuplicate = new MemoryStream(contentBytes))
{
await ProcessReferrersAndPushIndex(expected, contentDuplicate);
}
break;
default:
await InternalPushAsync(expected, content, reference, cancellationToken);
break;
}
}

/// <summary>
/// ProcessReferrersAndPushIndex processes the referrers for the given descriptor by deserializing its content
/// (either as an image manifest or image index), extracting relevant metadata
/// such as the subject, artifact type, and annotations, and then updates the
/// referrers index if applicable.
/// </summary>
/// <param name="desc"></param>
/// <param name="content"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private async Task ProcessReferrersAndPushIndex(Descriptor desc, Stream content, CancellationToken cancellationToken = default)
{
Descriptor? subject = null;
switch (desc.MediaType)
{
case MediaType.ImageIndex:
var indexManifest = JsonSerializer.Deserialize<Index>(content);
if (indexManifest?.Subject == null) return;
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
subject = indexManifest.Subject;
desc.ArtifactType = indexManifest.ArtifactType;
desc.Annotations = indexManifest.Annotations;
break;
case MediaType.ImageManifest:
var imageManifest = JsonSerializer.Deserialize<Manifest>(content);
if (imageManifest?.Subject == null) return;
subject = imageManifest.Subject;
desc.ArtifactType = string.IsNullOrEmpty(imageManifest.ArtifactType) ? imageManifest.Config.MediaType : imageManifest.ArtifactType;
desc.Annotations = imageManifest.Annotations;
break;
default:
return;

Check warning on line 238 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View check run for this annotation

Codecov / codecov/patch

src/OrasProject.Oras/Registry/Remote/ManifestStore.cs#L238

Added line #L238 was not covered by tests
}

Repository.ReferrerState = Referrers.ReferrerState.ReferrerNotSupported;
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
await UpdateReferrersIndex(subject, new Referrers.ReferrerChange(desc, Referrers.ReferrerOperation.ReferrerAdd));
}

/// <summary>
/// UpdateReferrersIndex updates the referrers index for a given subject by applying the specified referrer changes.
/// If the referrers index is updated, the new index is pushed to the repository. If referrers
/// garbage collection is not skipped, the old index is deleted.
/// </summary>
/// <param name="subject"></param>
/// <param name="referrerChange"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
private async Task UpdateReferrersIndex(Descriptor subject,
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
Referrers.ReferrerChange referrerChange, CancellationToken cancellationToken = default)
{
try
{
var referrersTag = Referrers.BuildReferrersTag(subject);
var (oldDesc, oldReferrers) = await PullReferrersIndexList(referrersTag);
var updatedReferrers =
Referrers.ApplyReferrerChanges(oldReferrers, referrerChange);

if (updatedReferrers.Count > 0 || repository.Options.SkipReferrersGc)
{
var (indexDesc, indexContent) = Index.GenerateIndex(updatedReferrers);
using (var content = new MemoryStream(indexContent))
{
await InternalPushAsync(indexDesc, content, referrersTag, cancellationToken).ConfigureAwait(false);
}
}

if (repository.Options.SkipReferrersGc || Descriptor.IsEmptyOrNull(oldDesc))
{
return;
}

await DeleteAsync(oldDesc, cancellationToken).ConfigureAwait(false);
}
catch (NoReferrerUpdateException)
{
return;

Check warning on line 282 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View check run for this annotation

Codecov / codecov/patch

src/OrasProject.Oras/Registry/Remote/ManifestStore.cs#L280-L282

Added lines #L280 - L282 were not covered by tests
}
}

/// <summary>
/// PullReferrersIndexList retrieves the referrers index list associated with the given referrers tag.
/// It fetches the index manifest from the repository, deserializes it into an `Index` object,
/// and returns the descriptor along with the list of manifests (referrers). If the referrers index is not found,
/// an empty descriptor and an empty list are returned.
/// </summary>
/// <param name="referrersTag"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
internal async Task<(Descriptor, IList<Descriptor>)> PullReferrersIndexList(string referrersTag, CancellationToken cancellationToken = default)
{
try
{
var (desc, content) = await FetchAsync(referrersTag);
var index = JsonSerializer.Deserialize<Index>(content);
Wwwsylvia marked this conversation as resolved.
Show resolved Hide resolved
if (index == null)
{
throw new JsonException("null index manifests list");

Check warning on line 303 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View check run for this annotation

Codecov / codecov/patch

src/OrasProject.Oras/Registry/Remote/ManifestStore.cs#L302-L303

Added lines #L302 - L303 were not covered by tests
}
return (desc, index.Manifests);
}
catch (NotFoundException)
{
return (Descriptor.EmptyDescriptor(), new List<Descriptor>());
}
}


/// <summary>
/// Pushes the manifest content, matching the expected descriptor.
/// </summary>
/// <param name="expected"></param>
/// <param name="stream"></param>
/// <param name="contentReference"></param>
/// <param name="cancellationToken"></param>
private async Task InternalPushAsync(Descriptor expected, Stream stream, string contentReference, CancellationToken cancellationToken)
private async Task InternalPushAsync(Descriptor expected, Stream stream, string contentReference,
CancellationToken cancellationToken)
{
var remoteReference = Repository.ParseReference(contentReference);
var url = new UriFactory(remoteReference, Repository.Options.PlainHttp).BuildRepositoryManifest();
Expand All @@ -177,6 +333,7 @@
{
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
}
response.CheckOciSubjectHeader(Repository);
response.VerifyContentDigest(expected.Digest);
}

Expand Down
Loading
Loading