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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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;

public class ReferrersStateAlreadySetException : Exception
{
public ReferrersStateAlreadySetException()
{
}

public ReferrersStateAlreadySetException(string? message)
: base(message)
{
}

public ReferrersStateAlreadySetException(string? message, Exception? inner)
: base(message, inner)
{
}
}
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 IsEmptyOrInvalid(Descriptor? descriptor)
{
return descriptor == null || string.IsNullOrEmpty(descriptor.Digest) || string.IsNullOrEmpty(descriptor.MediaType);
}

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

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

namespace OrasProject.Oras.Oci;

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

public Index() {}

[SetsRequiredMembers]
public Index(IList<Descriptor> manifests)
{
Manifests = manifests;
MediaType = Oci.MediaType.ImageIndex;
SchemaVersion = 2;
}

internal static (Descriptor, byte[]) GenerateIndex(IList<Descriptor> manifests)
{
var index = new Index(manifests);
var indexContent = JsonSerializer.SerializeToUtf8Bytes(index);
return (Descriptor.Create(indexContent, Oci.MediaType.ImageIndex), 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,27 @@ 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>
internal static void CheckOCISubjectHeader(this HttpResponseMessage response, Repository repository)
{
if (response.Headers.TryGetValues("OCI-Subject", out var values))
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
{
// Set it to Supported when the response header contains OCI-Subject
repository.ReferrersState = Referrers.ReferrersState.Supported;
}

// If the "OCI-Subject" header is NOT set, it means that either the manifest
// has no subject OR the referrers API is NOT supported by the registry.
//
// Since we don't know whether the pushed manifest has a subject or not,
// we do not set the ReferrerState to NotSupported here.
}

/// <summary>
/// Returns a descriptor generated from the response.
Expand Down Expand Up @@ -160,7 +180,7 @@ public static async Task<Descriptor> GenerateDescriptorAsync(this HttpResponseMe
{
serverDigest = serverHeaderDigest.FirstOrDefault();
if (!string.IsNullOrEmpty(serverDigest))
{
{
response.VerifyContentDigest(serverDigest);
}
}
Expand Down
178 changes: 177 additions & 1 deletion 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 @@ -153,9 +157,180 @@
public async Task PushAsync(Descriptor expected, Stream content, string reference, CancellationToken cancellationToken = default)
{
var remoteReference = Repository.ParseReference(reference);
await DoPushAsync(expected, content, remoteReference, cancellationToken).ConfigureAwait(false);
await PushWithIndexingAsync(expected, content, remoteReference, 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, Reference reference,
CancellationToken cancellationToken = default)
{
switch (expected.MediaType)
{
case MediaType.ImageManifest:
case MediaType.ImageIndex:
if (Repository.ReferrersState == Referrers.ReferrersState.Supported)
{
// Push the manifest straightaway when the registry supports referrers API
await DoPushAsync(expected, content, reference, cancellationToken).ConfigureAwait(false);
return;
}

var contentBytes = await content.ReadAllAsync(expected, cancellationToken).ConfigureAwait(false);
using (var contentDuplicate = new MemoryStream(contentBytes))
{
// Push the manifest when ReferrerState is Unknown or NotSupported
await DoPushAsync(expected, contentDuplicate, reference, cancellationToken).ConfigureAwait(false);
}
if (Repository.ReferrersState == Referrers.ReferrersState.Supported)
{
// Early exit when the registry supports Referrers API
// No need to index referrers list
return;
pat-pan marked this conversation as resolved.
Show resolved Hide resolved
}

using (var contentDuplicate = new MemoryStream(contentBytes))
{
// 1. Index the referrers list using referrers tag schema when manifest contains a subject field
// And the ReferrerState is not supported
// 2. Or do nothing when the manifest does not contain a subject field when ReferrerState is not supported/unknown
await ProcessReferrersAndPushIndex(expected, contentDuplicate, cancellationToken).ConfigureAwait(false);
}
break;
default:
await DoPushAsync(expected, content, reference, cancellationToken).ConfigureAwait(false);
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;
}
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 250 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View check run for this annotation

Codecov / codecov/patch

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

Added line #L250 was not covered by tests
}

Repository.ReferrersState = Referrers.ReferrersState.NotSupported;
await UpdateReferrersIndex(subject, new Referrers.ReferrerChange(desc, Referrers.ReferrerOperation.ReferrerAdd), cancellationToken).ConfigureAwait(false);
}

/// <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.
/// References:
/// - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#pushing-manifests-with-subject
/// - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests
/// </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)
{
// 1. pull the original referrers index list using referrers tag schema
var referrersTag = Referrers.BuildReferrersTag(subject);
var (oldDesc, oldReferrers) = await PullReferrersIndexList(referrersTag, cancellationToken).ConfigureAwait(false);

// 2. apply the referrer change to referrers list
var (updatedReferrers, updateRequired) =
Referrers.ApplyReferrerChanges(oldReferrers, referrerChange);
if (!updateRequired) return;

// 3. push the updated referrers list using referrers tag schema
if (updatedReferrers.Count > 0 || repository.Options.SkipReferrersGC)
{
// push a new index in either case:
// 1. the referrers list has been updated with a non-zero size
// 2. OR the updated referrers list is empty but referrers GC
// is skipped, in this case an empty index should still be pushed
// as the old index won't get deleted
var (indexDesc, indexContent) = Index.GenerateIndex(updatedReferrers);
using (var content = new MemoryStream(indexContent))
{
await DoPushAsync(indexDesc, content, Repository.ParseReference(referrersTag), cancellationToken).ConfigureAwait(false);
}
}

if (repository.Options.SkipReferrersGC || Descriptor.IsEmptyOrInvalid(oldDesc))
{
// Skip the delete process if SkipReferrersGC is set to true or the old Descriptor is empty or null
return;
}

// 4. delete the dangling original referrers index, if applicable
await DeleteAsync(oldDesc, cancellationToken).ConfigureAwait(false);

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

View workflow job for this annotation

GitHub Actions / Analyze (8.0.x)

Possible null reference argument for parameter 'target' in 'Task ManifestStore.DeleteAsync(Descriptor target, CancellationToken cancellationToken = default(CancellationToken))'.

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

View workflow job for this annotation

GitHub Actions / Analyze (8.0.x)

Possible null reference argument for parameter 'target' in 'Task ManifestStore.DeleteAsync(Descriptor target, CancellationToken cancellationToken = default(CancellationToken))'.

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

View workflow job for this annotation

GitHub Actions / build (8.0.x)

Possible null reference argument for parameter 'target' in 'Task ManifestStore.DeleteAsync(Descriptor target, CancellationToken cancellationToken = default(CancellationToken))'.
}

/// <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, cancellationToken).ConfigureAwait(false);
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 323 in src/OrasProject.Oras/Registry/Remote/ManifestStore.cs

View check run for this annotation

Codecov / codecov/patch

src/OrasProject.Oras/Registry/Remote/ManifestStore.cs#L322-L323

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


/// <summary>
/// Pushes the manifest content, matching the expected descriptor.
/// </summary>
Expand All @@ -176,6 +351,7 @@
{
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
}
response.CheckOCISubjectHeader(Repository);
response.VerifyContentDigest(expected.Digest);
}

Expand Down
Loading
Loading