Skip to content

Commit

Permalink
feat(referrers): delete manifest with subject (#174)
Browse files Browse the repository at this point in the history
Signed-off-by: Patrick Pan <[email protected]>
  • Loading branch information
pat-pan authored Feb 24, 2025
1 parent 1ac218a commit fab26f8
Show file tree
Hide file tree
Showing 13 changed files with 951 additions and 49 deletions.
33 changes: 33 additions & 0 deletions src/OrasProject.Oras/Exceptions/SizeLimitExceededException.cs
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 SizeLimitExceededException : Exception
{
public SizeLimitExceededException()
{
}

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

public SizeLimitExceededException(string? message, Exception? inner)
: base(message, inner)
{
}
}
12 changes: 12 additions & 0 deletions src/OrasProject.Oras/Registry/Reference.cs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ public static bool TryParse(string reference, [NotNullWhen(true)] out Reference?
}
}

public Reference(Reference other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));
}

_registry = other.Registry;
_repository = other.Repository;
ContentReference = other.ContentReference;
}

public Reference(string registry) => _registry = ValidateRegistry(registry);

public Reference(string registry, string? repository) : this(registry)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ internal static class HttpResponseMessageExtensions
/// </summary>
/// <param name="response"></param>
/// <returns></returns>
public static async Task<Exception> ParseErrorResponseAsync(this HttpResponseMessage response, CancellationToken cancellationToken)
public static async Task<ResponseException> ParseErrorResponseAsync(this HttpResponseMessage response, CancellationToken cancellationToken)
{
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return new ResponseException(response, body);
Expand Down Expand Up @@ -98,7 +98,7 @@ public static void VerifyContentDigest(this HttpResponseMessage response, string
}
if (contentDigest != expected)
{
throw new HttpIOException(HttpRequestError.InvalidResponse, $"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response; digest mismatch in Docker-Content-Digest: received {contentDigest} when expecting {digestStr}");
throw new HttpIOException(HttpRequestError.InvalidResponse, $"{response.RequestMessage!.Method} {response.RequestMessage.RequestUri}: invalid response; digest mismatch in Docker-Content-Digest: received {contentDigest} when expecting {expected}");
}
}

Expand Down
78 changes: 77 additions & 1 deletion src/OrasProject.Oras/Registry/Remote/ManifestStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -397,5 +397,81 @@ public async Task TagAsync(Descriptor descriptor, string reference, Cancellation
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task DeleteAsync(Descriptor target, CancellationToken cancellationToken = default)
=> await Repository.DeleteAsync(target, true, cancellationToken).ConfigureAwait(false);
=> await DeleteWithIndexing(target, cancellationToken).ConfigureAwait(false);

/// <summary>
/// DeleteWithIndexing deletes the specified target (Descriptor) from the repository,
/// handling referrer indexing if necessary.
/// </summary>
/// <param name="target">The target descriptor to delete.</param>
/// <param name="cancellationToken">A cancellation token to cancel the operation if needed. Defaults to default.</param>
/// <returns></returns>
private async Task DeleteWithIndexing(Descriptor target, CancellationToken cancellationToken = default)
{
switch (target.MediaType)
{
case MediaType.ImageManifest:
case MediaType.ImageIndex:
if (Repository.ReferrersState == Referrers.ReferrersState.Supported)
{
// referrers API is available, no client-side indexing needed
await Repository.DeleteAsync(target, true, cancellationToken).ConfigureAwait(false);
return;
}

Repository.LimitSize(target, Repository.Options.MaxMetadataBytes);
var manifest = await Repository.FetchAllAsync(target, cancellationToken).ConfigureAwait(false);
using (var manifestStream = new MemoryStream(manifest))
{
await IndexReferrersForDelete(target, manifestStream, cancellationToken).ConfigureAwait(false);
}
break;
}
await Repository.DeleteAsync(target, true, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// IndexReferrersForDelete indexes referrers for manifests with a subject field on manifest delete.
/// References:
/// - Latest spec: https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests
/// </summary>
/// <param name="target"></param>
/// <param name="manifestContent"></param>
/// <param name="cancellationToken"></param>
private async Task IndexReferrersForDelete(Descriptor target, Stream manifestContent, CancellationToken cancellationToken = default)
{
Descriptor subject;
switch (target.MediaType)
{
case MediaType.ImageManifest:
var imageManifest = JsonSerializer.Deserialize<Manifest>(manifestContent);
if (imageManifest?.Subject == null)
{
// no subject, no indexing needed
return;
}
subject = imageManifest.Subject;
break;
case MediaType.ImageIndex:
var imageIndex = JsonSerializer.Deserialize<Index>(manifestContent);
if (imageIndex?.Subject == null)
{
// no subject, no indexing needed
return;
}
subject = imageIndex.Subject;
break;
default:
return;
}

var isReferrersSupported = await Repository.PingReferrersAsync(cancellationToken).ConfigureAwait(false);
if (isReferrersSupported)
{
// referrers API is available, no client-side indexing needed
return;
}
await UpdateReferrersIndex(subject, new Referrers.ReferrerChange(target, Referrers.ReferrerOperation.Delete), cancellationToken)
.ConfigureAwait(false);
}
}
4 changes: 2 additions & 2 deletions src/OrasProject.Oras/Registry/Remote/Referrers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@
// limitations under the License.

using System.Collections.Generic;
using System.Linq;
using OrasProject.Oras.Content;
using OrasProject.Oras.Exceptions;
using OrasProject.Oras.Oci;

namespace OrasProject.Oras.Registry.Remote;
Expand All @@ -29,6 +27,8 @@ internal enum ReferrersState
}

internal record ReferrerChange(Descriptor Referrer, ReferrerOperation ReferrerOperation);

internal const string ZeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000";

internal enum ReferrerOperation
{
Expand Down
80 changes: 80 additions & 0 deletions src/OrasProject.Oras/Registry/Remote/Repository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ internal Referrers.ReferrersState ReferrersState
];

private RepositoryOptions _opts;

private readonly SemaphoreSlim _referrersPingSemaphore = new SemaphoreSlim(1, 1);

/// <summary>
/// Creates a client to the remote repository identified by a reference
Expand Down Expand Up @@ -369,6 +371,70 @@ internal Reference ParseReferenceFromContentReference(string reference)
public async Task MountAsync(Descriptor descriptor, string fromRepository, Func<CancellationToken, Task<Stream>>? getContent = null, CancellationToken cancellationToken = default)
=> await ((IMounter)Blobs).MountAsync(descriptor, fromRepository, getContent, cancellationToken).ConfigureAwait(false);

/// <summary>
/// PingReferrersAsync returns true if the Referrers API is available for the repository,
/// otherwise returns false
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
/// <exception cref="ResponseException"></exception>
/// <exception cref="Exception"></exception>
internal async Task<bool> PingReferrersAsync(CancellationToken cancellationToken = default)
{
switch (ReferrersState)
{
case Referrers.ReferrersState.Supported:
return true;
case Referrers.ReferrersState.NotSupported:
return false;
}

await _referrersPingSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
switch (ReferrersState)
{
case Referrers.ReferrersState.Supported:
return true;
case Referrers.ReferrersState.NotSupported:
return false;
}
// referrers state is unknown
// lock to limit the rate of pinging referrers API

var reference = new Reference(Options.Reference);
reference.ContentReference = Referrers.ZeroDigest;
var url = new UriFactory(reference, Options.PlainHttp).BuildReferrersUrl();
var request = new HttpRequestMessage(HttpMethod.Get, url);
var response = await Options.HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);

switch (response.StatusCode)
{
case HttpStatusCode.OK:
var supported = response.Content.Headers.ContentType?.MediaType == MediaType.ImageIndex;
SetReferrersState(supported);
return supported;
case HttpStatusCode.NotFound:
var err = await response.ParseErrorResponseAsync(cancellationToken)
.ConfigureAwait(false);
if (err.Errors?.First().Code == nameof(ResponseException.ErrorCode.NAME_UNKNOWN))
{
// referrer state is unknown because the repository is not found
throw err;
}

SetReferrersState(false);
return false;
default:
throw await response.ParseErrorResponseAsync(cancellationToken).ConfigureAwait(false);
}
}
finally
{
_referrersPingSemaphore.Release();
}
}

/// <summary>
/// SetReferrersState indicates the Referrers API state of the remote repository. true: supported; false: not supported.
/// SetReferrersState is valid only when it is called for the first time.
Expand All @@ -385,4 +451,18 @@ public void SetReferrersState(bool isSupported)
{
ReferrersState = isSupported ? Referrers.ReferrersState.Supported : Referrers.ReferrersState.NotSupported;
}


/// <summary>
/// LimitSize throws SizeLimitExceededException if the size of desc exceeds the limit limitSize.
/// </summary>
/// <param name="desc"></param>
/// <param name="limitSize"></param>
/// <exception cref="SizeLimitExceededException"></exception>
internal static void LimitSize(Descriptor desc, long limitSize) {
if (desc.Size > limitSize)
{
throw new SizeLimitExceededException($"content size {desc.Size} exceeds MaxMetadataBytes {limitSize}");
}
}
}
21 changes: 21 additions & 0 deletions src/OrasProject.Oras/Registry/Remote/RepositoryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,25 @@ public struct RepositoryOptions
/// - https://github.com/opencontainers/distribution-spec/blob/v1.1.0/spec.md#deleting-manifests
/// </summary>
public bool SkipReferrersGc { get; set; }

/// <summary>
/// MaxMetadataBytes specifies a limit on how many response bytes are allowed
/// in the server's response to the metadata APIs, such as catalog list, tag
/// list, and referrers list.
/// If less than or equal to zero, a default (currently 4MiB) is used.
/// </summary>
public long MaxMetadataBytes
{
get => _maxMetadataBytes > 0 ? _maxMetadataBytes : _defaultMaxMetadataBytes;
set => _maxMetadataBytes = value;
}

private long _maxMetadataBytes;

/// <summary>
/// _defaultMaxMetadataBytes specifies the default limit on how many response
/// bytes are allowed in the server's response to the metadata APIs.
/// See also: Repository.MaxMetadataBytes
/// </summary>
private const long _defaultMaxMetadataBytes = 4 * 1024 * 1024; // 4 MiB
}
68 changes: 35 additions & 33 deletions src/OrasProject.Oras/Registry/Remote/ResponseException.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace OrasProject.Oras.Registry.Remote;

public class ResponseException : HttpRequestException
{
{
public enum ErrorCode
{
NAME_UNKNOWN
}
public class Error
{
[JsonPropertyName("code")]
Expand All @@ -34,45 +36,45 @@ public class Error

[JsonPropertyName("detail")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public JsonElement? Detail { get; set; }
public JsonElement? Detail { get; set; }
}

public class ErrorResponse
{
private class ErrorResponse
{
[JsonPropertyName("errors")]
public required IList<Error> Errors { get; set; }
public required IList<Error> Errors { get; set; }
}

public HttpMethod? Method { get; }

public Uri? RequestUri { get; }

public IList<Error>? Errors { get; }

public ResponseException(HttpResponseMessage response, string? responseBody = null)
: this(response, responseBody, null)
{
}

public ResponseException(HttpResponseMessage response, string? responseBody, string? message)
: this(response, responseBody, response.StatusCode == HttpStatusCode.Unauthorized ? HttpRequestError.UserAuthenticationError : HttpRequestError.Unknown, message, null)
{
}

public ResponseException(HttpResponseMessage response, string? responseBody, HttpRequestError httpRequestError, string? message, Exception? inner)
: base(httpRequestError, message, inner, response.StatusCode)
{
var request = response.RequestMessage;
Method = request?.Method;
RequestUri = request?.RequestUri;
if (responseBody != null)
{
try
{
var errorResponse = JsonSerializer.Deserialize<ErrorResponse>(responseBody);
Errors = errorResponse?.Errors;
}
catch { }
public IList<Error>? Errors { get; }

public ResponseException(HttpResponseMessage response, string? responseBody = null)
: this(response, responseBody, null)
{
}

public ResponseException(HttpResponseMessage response, string? responseBody, string? message)
: this(response, responseBody, response.StatusCode == HttpStatusCode.Unauthorized ? HttpRequestError.UserAuthenticationError : HttpRequestError.Unknown, message, null)
{
}

public ResponseException(HttpResponseMessage response, string? responseBody, HttpRequestError httpRequestError, string? message, Exception? inner)
: base(httpRequestError, message, inner, response.StatusCode)
{
var request = response.RequestMessage;
Method = request?.Method;
RequestUri = request?.RequestUri;
if (responseBody != null)
{
try
{
var errorResponse = JsonSerializer.Deserialize<ErrorResponse>(responseBody);
Errors = errorResponse?.Errors;
}
catch { }
}
}
}
Loading

0 comments on commit fab26f8

Please sign in to comment.