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): delete manifest with subject #174

Merged
merged 44 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
e3e1217
push manifest with subject
Nov 18, 2024
846e173
add unit tests
Nov 19, 2024
7fca0cb
add unit tests
Nov 19, 2024
ec93662
Merge branch 'main' into feature/pushManifestWithSubject
Nov 19, 2024
7809549
resolve merge conflicts
Nov 19, 2024
c7e4418
add tests
Nov 19, 2024
a6d552f
add comments
Nov 19, 2024
685ab70
add unit test
Nov 20, 2024
1b9ade3
add unit tests
Nov 20, 2024
5041592
resolve comments
Nov 21, 2024
c26ccb6
add SetReferrersSupportLevel func and unit tests
Nov 21, 2024
ddbf048
remove NoReferrerUpdateException and update tests accordingly
Nov 22, 2024
d6e8499
add Index constructor
Nov 24, 2024
cf431f0
add license header
Nov 26, 2024
50e696e
resolve merge conflicts
Nov 26, 2024
856c0ef
add lock on SetReferrerState
Nov 29, 2024
fcb121e
simplify ApplyReferrerChanges
Nov 29, 2024
2b24e07
resolve comments
Dec 3, 2024
7df36ca
Merge branch 'main' into feature/pushManifestWithSubject
Dec 3, 2024
36a428f
delete manifest with subject
Dec 10, 2024
1813869
add reference clone
Dec 23, 2024
878535c
change variable naming
Dec 26, 2024
4bee2cc
fix merge conflict
Jan 6, 2025
c848ed2
polish pr
Jan 6, 2025
a7460f3
fix responseException style
Jan 7, 2025
20dcda9
Merge branch 'main' into feature/deleteManifestWithSubject
Jan 13, 2025
d7eb9b5
add unit tests
Jan 13, 2025
dfc538d
remove unnecessary changes
Jan 13, 2025
ff334ca
remove unnecessary changes
Jan 13, 2025
53f8e12
move ResponseException to Exceptions folder
Jan 13, 2025
b13de61
resolve comments
Jan 15, 2025
c5d222a
Merge branch 'main' into feature/deleteManifestWithSubject
Feb 5, 2025
21c714a
move response exception back to remote
Feb 5, 2025
a23d7eb
change to copy constructor
Feb 5, 2025
d5c38b9
dispose resource
Feb 6, 2025
3b78217
switch to use semaphore
Feb 10, 2025
fc2c590
change to enum
Feb 10, 2025
c37f5dc
Merge branch 'main' into feature/deleteManifestWithSubject
Feb 10, 2025
eaef044
Merge branch 'main' into feature/deleteManifestWithSubject
Feb 19, 2025
e7ae404
add maxmetadatabytes and address commments
Feb 19, 2025
d17ceb6
verify the manifest response
Feb 19, 2025
6851df2
fix test
Feb 21, 2025
7d0a8ef
address comments
Feb 23, 2025
90cb141
change to public for responsexception
Feb 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 Reference(Reference other)
{
if (other == null)
{
throw new ArgumentNullException(nameof(other));

Check warning on line 208 in src/OrasProject.Oras/Registry/Reference.cs

View check run for this annotation

Codecov / codecov/patch

src/OrasProject.Oras/Registry/Reference.cs#L207-L208

Added lines #L207 - L208 were not covered by tests
}

_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 @@ -29,7 +29,7 @@

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

Check warning on line 32 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 32 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 32 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 @@ -306,7 +306,7 @@
}

// 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 / 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))'.

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))'.
}

/// <summary>
Expand Down Expand Up @@ -397,5 +397,81 @@
/// <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;

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L465 was not covered by tests
}

var isReferrersSupported = await Repository.PingReferrersAsync(cancellationToken).ConfigureAwait(false);
if (isReferrersSupported)
{

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L470 was not covered by tests
// referrers API is available, no client-side indexing needed
return;

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

View check run for this annotation

Codecov / codecov/patch

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

Added line #L472 was not covered by tests
}
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 @@
];

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

Check warning on line 398 in src/OrasProject.Oras/Registry/Remote/Repository.cs

View check run for this annotation

Codecov / codecov/patch

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

Added line #L398 was not covered by tests
case Referrers.ReferrersState.NotSupported:
return false;

Check warning on line 400 in src/OrasProject.Oras/Registry/Remote/Repository.cs

View check run for this annotation

Codecov / codecov/patch

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

Added line #L400 was not covered by tests
}
// 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 @@
{
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
Loading