diff --git a/src/Docker.DotNet/DockerClient.cs b/src/Docker.DotNet/DockerClient.cs index 6328e8f7..a5da6fe1 100644 --- a/src/Docker.DotNet/DockerClient.cs +++ b/src/Docker.DotNet/DockerClient.cs @@ -35,6 +35,7 @@ internal DockerClient( Volumes = new VolumeOperations(this); Secrets = new SecretsOperations(this); Configs = new ConfigOperations(this); + Distribution = new DistributionOperations(this); Swarm = new SwarmOperations(this); Tasks = new TasksOperations(this); Plugin = new PluginOperations(this); @@ -57,6 +58,8 @@ internal DockerClient( public IConfigOperations Configs { get; } + public IDistributionOperations Distribution { get; } + public ISwarmOperations Swarm { get; } public ITasksOperations Tasks { get; } diff --git a/src/Docker.DotNet/Endpoints/DistributionOperations.cs b/src/Docker.DotNet/Endpoints/DistributionOperations.cs new file mode 100644 index 00000000..8a29a3dc --- /dev/null +++ b/src/Docker.DotNet/Endpoints/DistributionOperations.cs @@ -0,0 +1,30 @@ +namespace Docker.DotNet; + +internal class DistributionOperations : IDistributionOperations +{ + private static readonly ApiResponseErrorHandlingDelegate NoSuchImageHandler = (statusCode, responseBody) => + { + if (statusCode == HttpStatusCode.NotFound) + { + throw new DockerImageNotFoundException(statusCode, responseBody); + } + }; + + private readonly DockerClient _client; + + internal DistributionOperations(DockerClient client) + { + _client = client; + } + + public async Task InspectAsync(string name, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentNullException(nameof(name)); + } + + return await _client.MakeRequestAsync([NoSuchImageHandler], HttpMethod.Get, $"distribution/{name}/json", cancellationToken) + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Docker.DotNet/Endpoints/IDistributionOperations.cs b/src/Docker.DotNet/Endpoints/IDistributionOperations.cs new file mode 100644 index 00000000..85f72148 --- /dev/null +++ b/src/Docker.DotNet/Endpoints/IDistributionOperations.cs @@ -0,0 +1,18 @@ +namespace Docker.DotNet; + +public interface IDistributionOperations +{ + /// + /// Retrieves low-level information about an image from a registry. + /// + /// An image name or reference. + /// When triggered, the operation will stop at the next available time, if possible. + /// + /// The equivalent command in the Docker CLI is docker manifest inspect. + /// + /// One or more of the inputs was . + /// No such image was found. + /// The input is invalid or the daemon experienced an error. + /// The request failed due to an underlying issue such as network connectivity, DNS failure, server certificate validation or timeout. + Task InspectAsync(string name, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Docker.DotNet/IDockerClient.cs b/src/Docker.DotNet/IDockerClient.cs index 12830241..80e11387 100644 --- a/src/Docker.DotNet/IDockerClient.cs +++ b/src/Docker.DotNet/IDockerClient.cs @@ -18,6 +18,8 @@ public interface IDockerClient : IDisposable IConfigOperations Configs { get; } + IDistributionOperations Distribution { get; } + ISwarmOperations Swarm { get; } ITasksOperations Tasks { get; } diff --git a/src/Docker.DotNet/Models/DistributionInspectResponse.Generated.cs b/src/Docker.DotNet/Models/DistributionInspectResponse.Generated.cs new file mode 100644 index 00000000..081ba156 --- /dev/null +++ b/src/Docker.DotNet/Models/DistributionInspectResponse.Generated.cs @@ -0,0 +1,24 @@ +#nullable enable +namespace Docker.DotNet.Models +{ + /// + /// DistributionInspect describes the result obtained from contacting the + /// registry to retrieve image metadata + /// + public class DistributionInspectResponse // (registry.DistributionInspect) + { + /// + /// Descriptor contains information about the manifest, including + /// the content addressable digest + /// + [JsonPropertyName("Descriptor")] + public Descriptor Descriptor { get; set; } = default!; + + /// + /// Platforms contains the list of platforms supported by the image, + /// obtained by parsing the manifest + /// + [JsonPropertyName("Platforms")] + public IList Platforms { get; set; } = default!; + } +} diff --git a/src/Docker.DotNet/Models/DockerModelsJsonSerializerContext.Generated.cs b/src/Docker.DotNet/Models/DockerModelsJsonSerializerContext.Generated.cs index 2f904917..90224138 100644 --- a/src/Docker.DotNet/Models/DockerModelsJsonSerializerContext.Generated.cs +++ b/src/Docker.DotNet/Models/DockerModelsJsonSerializerContext.Generated.cs @@ -58,6 +58,7 @@ namespace Docker.DotNet.Models [JsonSerializable(typeof(DeviceRequest))] [JsonSerializable(typeof(DiscreteGenericResource))] [JsonSerializable(typeof(DispatcherConfig))] + [JsonSerializable(typeof(DistributionInspectResponse))] [JsonSerializable(typeof(DockerOCIImageConfig))] [JsonSerializable(typeof(DockerOCIImageConfigExt))] [JsonSerializable(typeof(Driver))] diff --git a/test/Docker.DotNet.Tests/IDistributionOperationsTests.cs b/test/Docker.DotNet.Tests/IDistributionOperationsTests.cs new file mode 100644 index 00000000..5d8d592c --- /dev/null +++ b/test/Docker.DotNet.Tests/IDistributionOperationsTests.cs @@ -0,0 +1,48 @@ +namespace Docker.DotNet.Tests; + +[Collection(nameof(TestCollection))] +public class IDistributionOperationsTests +{ + private readonly TestFixture _testFixture; + + public IDistributionOperationsTests(TestFixture testFixture) + { + _testFixture = testFixture; + } + + [Fact] + public async Task InspectAsync_ReturnsDescriptorAndPlatforms() + { + var response = await _testFixture.DockerClient.Distribution.InspectAsync( + "alpine:3.20", + _testFixture.Cts.Token); + + Assert.NotNull(response); + Assert.NotNull(response.Descriptor); + Assert.StartsWith("sha256:", response.Descriptor.Digest); + Assert.NotEmpty(response.Platforms); + } + + [Fact] + public Task InspectAsync_UnknownImage_ThrowsDockerImageNotFoundException() + { + return Assert.ThrowsAsync( + () => _testFixture.DockerClient.Distribution.InspectAsync( + "alpine:does-not-exist", + _testFixture.Cts.Token)); + } + + [Fact] + public async Task InspectAsync_NullOrEmptyName_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync( + () => _testFixture.DockerClient.Distribution.InspectAsync( + null!, + _testFixture.Cts.Token)); + + await Assert.ThrowsAsync( + () => _testFixture.DockerClient.Distribution.InspectAsync( + string.Empty, + _testFixture.Cts.Token)); + } +} \ No newline at end of file diff --git a/tools/specgen/specgen.go b/tools/specgen/specgen.go index 36cc8cbb..7645d413 100644 --- a/tools/specgen/specgen.go +++ b/tools/specgen/specgen.go @@ -123,6 +123,9 @@ var typesToDisambiguate = map[string]*CSModelType{ typeToKey(reflect.TypeOf(volume.Secret{})): {Name: "VolumeSecret"}, typeToKey(reflect.TypeOf(volume.Topology{})): {Name: "VolumeTopology"}, typeToKey(reflect.TypeOf(registry.AuthResponse{})): {Name: "AuthResponse"}, + typeToKey(reflect.TypeOf(registry.DistributionInspect{})): { + Name: "DistributionInspectResponse", + }, typeToKey(reflect.TypeOf(registry.SearchResult{})): {Name: "ImageSearchResponse"}, typeToKey(reflect.TypeOf(swarm.RuntimeSpec{})): {Name: "SwarmRuntimeSpec"}, typeToKey(reflect.TypeOf(swarm.ConfigSpec{})): {Name: "SwarmConfigSpec"}, @@ -343,6 +346,9 @@ var dockerTypesToReflect = []reflect.Type{ reflect.TypeOf(events.Actor{}), reflect.TypeOf(events.Message{}), + // GET /distribution/{name}/json + reflect.TypeOf(registry.DistributionInspect{}), + // POST /images/create reflect.TypeOf(ImagesCreateParameters{}), reflect.TypeOf(jsonstream.Message{}),