Skip to content

Commit

Permalink
feat(referrers): push manifest with subject (oras-project#163)
Browse files Browse the repository at this point in the history
Signed-off-by: Patrick Pan <[email protected]>
  • Loading branch information
pat-pan authored Jan 6, 2025
1 parent 336cd2c commit 9ec969b
Show file tree
Hide file tree
Showing 17 changed files with 1,538 additions and 57 deletions.
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)
{
}
}
7 changes: 5 additions & 2 deletions src/OrasProject.Oras/Oci/Descriptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json.Serialization;

namespace OrasProject.Oras.Oci;
Expand Down Expand Up @@ -70,4 +68,9 @@ public static Descriptor Create(Span<byte> data, string mediaType)
};

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

internal static bool IsNullOrInvalid(Descriptor? descriptor)
{
return descriptor == null || string.IsNullOrEmpty(descriptor.Digest) || string.IsNullOrEmpty(descriptor.MediaType);
}
}
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,7 @@ 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 @@ -102,6 +102,26 @@ public static void VerifyContentDigest(this HttpResponseMessage response, string
}
}

/// <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 (repository.ReferrersState == Referrers.ReferrersState.Unknown && response.Headers.Contains("OCI-Subject"))
{
// Set it to Supported when the response header contains OCI-Subject
repository.SetReferrersState(true);
}

// 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.
/// </summary>
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
184 changes: 183 additions & 1 deletion src/OrasProject.Oras/Registry/Remote/ManifestStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@
using OrasProject.Oras.Exceptions;
using OrasProject.Oras.Oci;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
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;

Expand Down Expand Up @@ -153,9 +158,185 @@ public async Task PushAsync(Descriptor expected, Stream content, CancellationTok
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;
}

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;
}

// In this case, the manifest contains a subject field and OCI-Subject Header is not set after pushing the manifest to the registry,
// which indicates that the registry does not support referrers API
Repository.SetReferrersState(false);
await UpdateReferrersIndex(subject, new Referrers.ReferrerChange(desc, Referrers.ReferrerOperation.Add), 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,
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.IsNullOrInvalid(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 309 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))'.

Check warning on line 309 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 309 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))'.
}

/// <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);
if (index == null)
{
throw new JsonException($"null index manifests list when pulling referrers index list for referrers tag {referrersTag}");
}
return (desc, index.Manifests);
}
catch (NotFoundException)
{
return (null, ImmutableArray<Descriptor>.Empty);
}
}


/// <summary>
/// Pushes the manifest content, matching the expected descriptor.
/// </summary>
Expand All @@ -176,6 +357,7 @@ private async Task DoPushAsync(Descriptor expected, Stream stream, Reference rem
{
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
}
response.CheckOciSubjectHeader(Repository);
response.VerifyContentDigest(expected.Digest);
}

Expand Down
Loading

0 comments on commit 9ec969b

Please sign in to comment.