diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index afb1e95..7bd6e34 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,14 +3,14 @@ "isRoot": true, "tools": { "dotnet-stryker": { - "version": "4.8.1", + "version": "4.12.0", "commands": [ "dotnet-stryker" ], "rollForward": false }, "nuke.globaltool": { - "version": "9.0.4", + "version": "10.1.0", "commands": [ "nuke" ], diff --git a/.editorconfig b/.editorconfig index a121307..3e97ad8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -232,6 +232,7 @@ dotnet_diagnostic.SA1000.severity = suggestion # Need to configure formatting in dotnet_diagnostic.SA1313.severity = suggestion # Rider disagrees? [**/*.{Tests,E2ETests,ArchTests}*/**.cs] +dotnet_diagnostic.CA1515.severity = none dotnet_diagnostic.CA1852.severity = warning dotnet_diagnostic.CA1816.severity = warning dotnet_diagnostic.CA2007.severity = none @@ -242,3 +243,10 @@ dotnet_diagnostic.SA1600.severity = none dotnet_diagnostic.SA1118.severity = none # Await *Async instead. dotnet_diagnostic.S6966.severity = none +# * creates a new instance of * which is never used +dotnet_diagnostic.CA1806.severity = suggestion +# A property should not follow a method +dotnet_diagnostic.SA1201.severity = none +# Instance of NullForgiving operator without justification detected +dotnet_diagnostic.NX0001.severity = suggestion +dotnet_diagnostic.NX0002.severity = suggestion diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json index 2e44ebd..ae812b7 100644 --- a/.nuke/build.schema.json +++ b/.nuke/build.schema.json @@ -25,6 +25,8 @@ "type": "string", "enum": [ "Build", + "CheckBuildWarnings", + "CheckWarnings", "Clean", "CleanProjects", "Pack", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..beb4ffa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Project structure + +- `dotnet/ImageReferences/` - Strongly-typed container image references for .NET. +- `dotnet/ImageReferences.Extensions.Nuke/` - NUKE build extensions + +Each project may have: + +- its own AGENTS.md file with more detailed guidance +- related test projects, whose name will follow the pattern `.Tests` +- its own README.md file used for the NuGet package diff --git a/Directory.Build.props b/Directory.Build.props index a6ff2cb..6766019 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.0.0-preview.2.local + 0.0.0-local hojmark Copyright (c) hojmark https://github.com/hojmark/labs @@ -15,11 +15,12 @@ enable false All - true + true + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index b805747..ac124e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,10 +12,11 @@ - + + - + - + \ No newline at end of file diff --git a/HLabs.Build.slnx b/HLabs.Build.slnx new file mode 100644 index 0000000..b7bb430 --- /dev/null +++ b/HLabs.Build.slnx @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/HLabs.slnx b/HLabs.slnx index 8401764..690c325 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -4,11 +4,18 @@ + + + + + + + - - - + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bf3024 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# HLabs + +A collection of .NET libraries. + +## NuGet packages + +| Name | Version | Description | +|-------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------| +| [HLabs.ImageReferences](dotnet/ImageReferences/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.svg)](https://www.nuget.org/packages/HLabs.ImageReferences/) | Strongly-typed container image references | +| [HLabs.ImageReferences.Extensions.Nuke](dotnet/ImageReferences.Extensions.Nuke/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.Extensions.Nuke.svg)](https://www.nuget.org/packages/HLabs.ImageReferences.Extensions.Nuke/) | NUKE build extension methods | diff --git a/build/NukeBuild.Build.cs b/build/NukeBuild.Build.cs index f839759..7f1baf3 100644 --- a/build/NukeBuild.Build.cs +++ b/build/NukeBuild.Build.cs @@ -15,6 +15,7 @@ sealed partial class NukeBuild { Target Build => _ => _ .DependsOn( Restore ) + .Triggers( CheckWarnings ) .Executes( () => { //using var _ = new OperationTimer( nameof(Build) ); @@ -24,7 +25,7 @@ sealed partial class NukeBuild { .SetProjectFile( Solution ) .SetConfiguration( Configuration ) //TODO.SetVersionProperties( version ) - //.SetBinaryLog( BinaryBuildLogName ) + .SetBinaryLog( BinaryBuildLogName ) .EnableNoLogo() .EnableNoRestore() ); diff --git a/build/NukeBuild.CheckWarnings.cs b/build/NukeBuild.CheckWarnings.cs new file mode 100644 index 0000000..96b8742 --- /dev/null +++ b/build/NukeBuild.CheckWarnings.cs @@ -0,0 +1,34 @@ +using Nuke.Common; +using Serilog; +using Utils; + +// ReSharper disable VariableHidesOuterVariable +// ReSharper disable AllUnderscoreLocalParameterName +// ReSharper disable UnusedMember.Local + +sealed partial class NukeBuild { + private const string BinaryBuildLogName = "build.binlog"; + + Target CheckWarnings => _ => _ + .DependsOn( CheckBuildWarnings ); + + Target CheckBuildWarnings => _ => _ + .After( Build ) + .Executes( () => { + var warnings = BinaryLogReader.GetWarnings( BinaryBuildLogName ); + + foreach ( var warning in warnings ) { + Log.Information( warning ); + } + + var hasWarnings = warnings.Length != 0; + + if ( hasWarnings ) { + Log.Error( "Found {Count} build warnings", warnings.Length ); + throw new Exception( $"Found {warnings.Length} build warnings" ); + } + + Log.Information( "🟢 No build warnings found" ); + } + ); +} \ No newline at end of file diff --git a/build/NukeBuild.Clean.cs b/build/NukeBuild.Clean.cs index 3cee5e6..0e54bf7 100644 --- a/build/NukeBuild.Clean.cs +++ b/build/NukeBuild.Clean.cs @@ -1,4 +1,3 @@ -using System.Linq; using Nuke.Common; using Nuke.Common.IO; using Nuke.Common.ProjectModel; diff --git a/build/NukeBuild.Pack.cs b/build/NukeBuild.Pack.cs index a733827..a4468ed 100644 --- a/build/NukeBuild.Pack.cs +++ b/build/NukeBuild.Pack.cs @@ -1,4 +1,5 @@ -using System.IO; +using System.IO.Compression; +using System.Xml.Linq; using Nuke.Common; using Nuke.Common.IO; using Nuke.Common.Tools.DotNet; @@ -35,6 +36,7 @@ internal partial class NukeBuild { Log.Information( "Publishing to local registry {Path}", Paths.NuGetLocalRegistry ); foreach ( var nupkg in Directory.GetFiles( Paths.NuGetArtifacts, "*.nupkg" ) ) { + ClearNuGetCacheFor( nupkg ); var fileName = Path.GetFileName( nupkg ); var path = Path.Combine( Paths.NuGetLocalRegistry, fileName ); File.Copy( nupkg, path, overwrite: true ); @@ -42,4 +44,67 @@ internal partial class NukeBuild { } } ); + + void ClearNuGetCacheFor( string nupkgPath ) { + var (packageId, version) = ReadPackageIdentity( nupkgPath ); + + if ( packageId is null || version is null ) + return; + + var globalPackages = Path.Combine( + Environment.GetFolderPath( Environment.SpecialFolder.UserProfile ), + ".nuget", + "packages" ); + + var cachedPath = Path.Combine( + globalPackages, + packageId.ToLowerInvariant(), + version ); + + if ( !Directory.Exists( cachedPath ) ) + return; + + Log.Information( + "Clearing NuGet cache for {Id} {Version} at {Path}", + packageId, + version, + cachedPath ); + + Directory.Delete( cachedPath, recursive: true ); + } + + private (string? Id, string? Version) ReadPackageIdentity( string nupkgPath ) { + using var archive = ZipFile.OpenRead( nupkgPath ); + + var nuspecEntry = archive.Entries + .FirstOrDefault( e => e.FullName.EndsWith( ".nuspec", StringComparison.OrdinalIgnoreCase ) ); + + if ( nuspecEntry is null ) { + Log.Warning( "No .nuspec found in {Nupkg}", nupkgPath ); + return ( null, null ); + } + + using var stream = nuspecEntry.Open(); + var document = XDocument.Load( stream ); + + var metadata = document + .Descendants() + .FirstOrDefault( e => e.Name.LocalName == "metadata" ); + + var id = metadata? + .Elements() + .FirstOrDefault( e => e.Name.LocalName == "id" ) + ?.Value; + + var version = metadata? + .Elements() + .FirstOrDefault( e => e.Name.LocalName == "version" ) + ?.Value; + + if ( id is null || version is null ) { + Log.Warning( "Could not read id/version from nuspec in {Nupkg}", nupkgPath ); + } + + return ( id, version ); + } } \ No newline at end of file diff --git a/build/Utils/BinaryLogReader.cs b/build/Utils/BinaryLogReader.cs new file mode 100644 index 0000000..aa73779 --- /dev/null +++ b/build/Utils/BinaryLogReader.cs @@ -0,0 +1,25 @@ +using System.Text.RegularExpressions; +using Nuke.Common.Tooling; +using Nuke.Common.Tools.DotNet; +using static Nuke.Common.Tools.DotNet.DotNetTasks; + +namespace Utils; + +internal static class BinaryLogReader { + public static string[] GetWarnings( string binaryLogName ) { + string warningsLogName = $"{binaryLogName}-warnings-only.log"; + + DotNetMSBuild( s => s + .SetTargetPath( binaryLogName ) + .SetNoConsoleLogger( true ) + .AddProcessAdditionalArguments( "-fl", $"-flp:logfile={warningsLogName};warningsonly" ) + ); + + return File.ReadAllLines( warningsLogName ) + .Select( + // Remove the leading " 4>" part + line => Regex.Replace( line, @"^\s*\d+>", string.Empty ) + ) + .ToArray(); + } +} \ No newline at end of file diff --git a/build/_build.csproj b/build/_build.csproj index 5e9086c..2f942ef 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -10,6 +10,8 @@ ..\dotnet 1 false + enable + enable diff --git a/dotnet/Containers.Tests/ImageReferenceTests.cs b/dotnet/Containers.Tests/ImageReferenceTests.cs deleted file mode 100644 index cd22b56..0000000 --- a/dotnet/Containers.Tests/ImageReferenceTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Semver; - -namespace HLabs.Containers.Tests; - -internal sealed class ImageReferenceTests { - public static IEnumerable<(ImageReference, string)> SuccessTestCases { - get { - yield return ( - ImageReference.Localhost( "drift", Tag.Latest ), - "localhost:5000/drift:latest" - ); - yield return ( - ImageReference.DockerIo( "hojmark", "drift", Tag.Version( new SemVersion( 1, 21, 1 ) ) ), - "docker.io/hojmark/drift:1.21.1" - ); - yield return ( - ImageReference.Localhost( "drift", Tag.Version( new SemVersion( 2, 0, 0 ) ) ), - "localhost:5000/drift:2.0.0" - ); - yield return ( - ImageReference.Localhost( "drift", Tag.Dev ), "localhost:5000/drift:dev" - ); - } - } - - public static IEnumerable> FailureTestCases { - get { - yield return new Lazy( () => - ImageReference.DockerIo( "drift", string.Empty, Tag.Version( new SemVersion( 1, 21, 1 ) ) ) ); - yield return new Lazy( () => - ImageReference.DockerIo( string.Empty, "drift", Tag.Version( new SemVersion( 2, 0, 0 ) ) ) ); - yield return new Lazy( () => - ImageReference.DockerIo( "hojmark", string.Empty, Tag.Latest ) ); - yield return new Lazy( () => - ImageReference.DockerIo( "hojmark", " ", Tag.Dev ) ); - yield return new Lazy( () => - ImageReference.DockerIo( "hojmark", "/", Tag.Dev ) ); - } - } - - [Test] - [MethodDataSource( nameof(SuccessTestCases) )] - public async Task SerializationSuccessTest( ImageReference reference, string expected ) { - await Assert.That( reference.ToString() ).IsEqualTo( expected ); - } - - [Test] - [MethodDataSource( nameof(FailureTestCases) )] - public void SerializationFailureTest( Lazy reference ) { - Assert.Throws( () => _ = reference.Value ); - } -} \ No newline at end of file diff --git a/dotnet/Containers/ContainerRegistry.cs b/dotnet/Containers/ContainerRegistry.cs deleted file mode 100644 index e9b6597..0000000 --- a/dotnet/Containers/ContainerRegistry.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace HLabs.Containers; - -public abstract record ContainerRegistry { - protected abstract string Host { - get; - } - - // Sealed to prevent children from generating their own auto-ToString implementation - public sealed override string ToString() { - return Host; - } -} - -public sealed record DockerIoRegistry : ContainerRegistry { - public static readonly DockerIoRegistry Instance = new(); - - private DockerIoRegistry() { - } - - protected override string Host => "docker.io"; -} - -public sealed record LocalhostRegistry : ContainerRegistry { - public static readonly LocalhostRegistry Instance = new(); - - private LocalhostRegistry() { - } - - protected override string Host => "localhost:5000"; -} \ No newline at end of file diff --git a/dotnet/Containers/ImageReference.cs b/dotnet/Containers/ImageReference.cs deleted file mode 100644 index b683f69..0000000 --- a/dotnet/Containers/ImageReference.cs +++ /dev/null @@ -1,88 +0,0 @@ -using Semver; - -namespace HLabs.Containers; - -public record ImageReference { - public required ContainerRegistry Host { - get; - init; - } - - public string? Namespace { - get; - init; - } - - public required string Repository { - get; - init; - } - - public required Tag Tag { - get; - init; - } - - public static ImageReference Localhost( string repository, SemVersion version ) { - return Localhost( repository, Tag.Version( version ) ); - } - - public static ImageReference Localhost( string repository, Tag tag ) { - ValidateOrThrow( repository ); - - return new ImageReference { Host = LocalhostRegistry.Instance, Repository = repository, Tag = tag }; - } - - public static ImageReference DockerIo( string @namespace, string repository, SemVersion version ) { - return DockerIo( @namespace, repository, Tag.Version( version ) ); - } - - public static ImageReference DockerIo( string @namespace, string repository, Tag tag ) { - ValidateOrThrow( @namespace, repository ); - - return new ImageReference { - Host = DockerIoRegistry.Instance, Namespace = @namespace, Repository = repository, Tag = tag - }; - } - - public override string ToString() { - var @namespace = Namespace == null ? string.Empty : $"{Namespace}/"; - var name = $"{@namespace}{Repository}"; - - return $"{Host}/{name}:{Tag}"; - } - - private static void ValidateOrThrow( string @namespace, string repository ) { - if ( @namespace.Contains( '/', StringComparison.InvariantCulture ) ) { - throw new ArgumentException( "Contains /", nameof(@namespace) ); - } - - if ( @namespace.Trim().Length != @namespace.Length ) { - throw new ArgumentException( "Contains whitespace", nameof(@namespace) ); - } - - if ( string.IsNullOrWhiteSpace( @namespace ) ) { - throw new ArgumentException( "Cannot be null or empty", nameof(@namespace) ); - } - - ValidateOrThrow( repository ); - } - - private static void ValidateOrThrow( string repository ) { - if ( repository.Contains( '/', StringComparison.InvariantCulture ) ) { - throw new ArgumentException( "Contains /", nameof(repository) ); - } - - if ( repository.Trim().Length != repository.Length ) { - throw new ArgumentException( "Contains whitespace", nameof(repository) ); - } - - if ( string.IsNullOrWhiteSpace( repository ) ) { - throw new ArgumentException( "Cannot be null or empty", nameof(repository) ); - } - } - - public ImageReference WithTag( Tag tag ) { - return this with { Tag = tag }; - } -} \ No newline at end of file diff --git a/dotnet/Containers/README.md b/dotnet/Containers/README.md deleted file mode 100644 index 489cd5d..0000000 --- a/dotnet/Containers/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# HLabs.Containers - -A .NET library for describing, validating, and manipulating container image references. - -Provides strongly typed representations of container registries, image references, and tags, making it easy to work with -container images .NET projects. - ---- - -## Installation - -Install via NuGet: - -```bash -dotnet add package HLabs.Containers -``` - -## Usage -```bash -TODO -``` \ No newline at end of file diff --git a/dotnet/Containers/Tag.cs b/dotnet/Containers/Tag.cs deleted file mode 100644 index 3daad25..0000000 --- a/dotnet/Containers/Tag.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Semver; - -namespace HLabs.Containers; - -public record Tag { - internal Tag( string value ) { - Value = value; - } - - private string Value { - get; - } - - // Sealed to prevent children from generating their own auto-ToString implementation - public sealed override string ToString() { - return Value; - } -} - -public record LatestTag : Tag { - public static readonly LatestTag Instance = new(); - - private LatestTag() : base( "latest" ) { - } -} - -public static class TagExtensions { - // Bug: https://github.com/dotnet/sdk/issues/51681 -#pragma warning disable CA1034 - extension( Tag ) { -#pragma warning restore CA1034 - public static Tag Latest => LatestTag.Instance; - public static Tag Dev => new("dev"); - - public static Tag Version( SemVersion version ) { - ArgumentNullException.ThrowIfNull( version ); - return new Tag( version.WithoutMetadata().ToString() ); - } - } -} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs new file mode 100644 index 0000000..001a6a6 --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -0,0 +1,19 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.ImageReferences.Extensions.Nuke; + +/// +/// Extension methods for to work with strongly-typed registries. +/// +public static class DockerLoginSettingsExtensions { + /// + /// Sets the registry server to log in to using a . + /// + /// The Docker login settings. + /// The registry to log in to. + /// The updated settings. + public static DockerLoginSettings SetServer( this DockerLoginSettings settings, Registry registry ) { + ArgumentNullException.ThrowIfNull( registry ); + return global::Nuke.Common.Tools.Docker.DockerLoginSettingsExtensions.SetServer( settings, registry.ToString() ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs new file mode 100644 index 0000000..2fc255f --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -0,0 +1,19 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.ImageReferences.Extensions.Nuke; + +/// +/// Extension methods for to work with type-safe objects. +/// +public static class DockerLogoutSettingsExtensions { + /// + /// Sets the server (registry) for the docker logout command. + /// + /// The docker logout settings. + /// The registry to logout from. + /// The modified settings. + public static DockerLogoutSettings SetServer( this DockerLogoutSettings settings, Registry registry ) { + ArgumentNullException.ThrowIfNull( registry ); + return global::Nuke.Common.Tools.Docker.DockerLogoutSettingsExtensions.SetServer( settings, registry.ToString() ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs new file mode 100644 index 0000000..1f6820e --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs @@ -0,0 +1,19 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.ImageReferences.Extensions.Nuke; + +/// +/// Extension methods for to work with strongly-typed image references. +/// +public static class DockerPushSettingsExtensions { + /// + /// Sets the image name to push using a . + /// + /// The Docker push settings. + /// The image reference to push. + /// The updated settings. + public static DockerPushSettings SetName( this DockerPushSettings settings, QualifiedImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerPushSettingsExtensions.SetName( settings, imageReference.ToString() ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs new file mode 100644 index 0000000..00cac49 --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs @@ -0,0 +1,61 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.ImageReferences.Extensions.Nuke; + +/// +/// Extension methods for to work with strongly-typed image references. +/// +public static class DockerTagSettingsExtensions { + /// + /// Sets the source image using an . + /// + /// The Docker tag settings. + /// The source image ID. + /// The updated settings. + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, ImageId imageId ) { + ArgumentNullException.ThrowIfNull( imageId ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( settings, imageId.ToString() ); + } + + /// + /// Sets the source image using a . + /// + /// The Docker tag settings. + /// The source image reference. + /// The updated settings. + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, QualifiedImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( + settings, + imageReference.ToString() + ); + } + + /// + /// Sets the source image using a . + /// + /// The Docker tag settings. + /// The source image reference. + /// The updated settings. + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, CanonicalImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( + settings, + imageReference.ToString() + ); + } + + /// + /// Sets the target image using a . + /// + /// The Docker tag settings. + /// The target image reference. + /// The updated settings. + public static DockerTagSettings SetTargetImage( this DockerTagSettings settings, QualifiedImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetTargetImage( + settings, + imageReference.ToString() + ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj new file mode 100644 index 0000000..747eecd --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj @@ -0,0 +1,21 @@ + + + + NUKE extensions for HLabs.ImageReferences + NUKE Docker container containers + true + + + + + + + + + + + + + + + diff --git a/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs new file mode 100644 index 0000000..51692f9 --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs @@ -0,0 +1,47 @@ +using Nuke.Common.Tooling; +using Nuke.Common.Tools.Docker; + +namespace HLabs.ImageReferences.Extensions.Nuke; + +/// +/// Extension methods for working with local Docker repository to retrieve image metadata. +/// +public static class LocalDockerRepositoryExtensions { + /// + /// Gets the digest for a local Docker image by its image ID. + /// + /// The image ID to look up. + /// The digest of the image. + public static Digest GetDigest( this ImageId imageId ) { + var output = DockerTasks.DockerImageLs( s => s + .SetNoTrunc( true ) + .SetDigests( true ) + .SetProcessOutputLogging( false ) // Set to true to debug + .SetFormat( "{{.ID}} {{.Digest}}" ) + ); + + return GetDigestForImageId( output, imageId ); + } + + private static Digest GetDigestForImageId( IEnumerable lines, ImageId imageId ) { + return GetDigestForImageId( lines.Select( l => l.Text ).ToArray(), imageId ); + } + + private static Digest GetDigestForImageId( string[] lines, ImageId targetImageId ) { + foreach ( var line in lines ) { + var parts = line.Split( ' ', 2 ); + if ( parts.Length != 2 ) { + throw new FormatException( $"Unexpected line from docker image ls: '{line}'" ); + } + + var imageId = parts[0]; + var digest = parts[1]; + + if ( imageId == targetImageId.ToString() ) { + return new Digest( digest ); + } + } + + throw new InvalidOperationException( $"No digest found for image ID {targetImageId}" ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Extensions.Nuke/README.md b/dotnet/ImageReferences.Extensions.Nuke/README.md new file mode 100644 index 0000000..76767b5 --- /dev/null +++ b/dotnet/ImageReferences.Extensions.Nuke/README.md @@ -0,0 +1,3 @@ +# `HLabs.ImageReferences.Extensions.Nuke` + +NUKE extensions for [`HLabs.ImageReferences`](https://www.nuget.org/packages/HLabs.ImageReferences/) \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs new file mode 100644 index 0000000..3620c1a --- /dev/null +++ b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs @@ -0,0 +1,316 @@ +namespace HLabs.ImageReferences.Tests; + +internal sealed class CanonicalImageRefTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + private const string ValidDigest2 = "sha256:b5ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + // ----------------------- + // Construction + // ----------------------- + [Test] + public async Task ConstructFromQualifiedRef() { + var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ) + .Qualify() + .Canonicalize(); + + await Assert.That( canonical.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( canonical.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task ConstructFromPartialRef() { + var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + + await Assert.That( canonical.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task ConstructWithCustomRegistry() { + var canonical = new PartialImageRef( + Registry.GitHub, + new Namespace( "myorg" ), + new Repository( "myapp" ), + new Digest( ValidDigest ) + ) + .Canonicalize(); + + await Assert.That( canonical.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( canonical.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "myapp" ) ); + } + + [Test] + public async Task ConstructWithOptionalTag() { + var canonical = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + + await Assert.That( canonical.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + // ----------------------- + // ToString + // ----------------------- + [Test] + public async Task ToStringWithDigestOnly() { + var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + await Assert.That( canonical.ToString() ).IsEqualTo( $"docker.io/library/nginx@{ValidDigest}" ); + } + + [Test] + public async Task ToStringWithTagAndDigest() { + var canonical = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + await Assert.That( canonical.ToString() ).IsEqualTo( $"docker.io/library/nginx:latest@{ValidDigest}" ); + } + + [Test] + public async Task ToStringWithCustomRegistry() { + var canonical = new PartialImageRef( + Registry.GitHub, + new Namespace( "myorg" ), + new Repository( "myapp" ), + new Digest( ValidDigest ) + ) + .Canonicalize(); + await Assert.That( canonical.ToString() ).IsEqualTo( $"ghcr.io/myorg/myapp@{ValidDigest}" ); + } + + [Test] + public async Task ToStringWithoutNamespace() { + var canonical = new PartialImageRef( Registry.Localhost, "myapp", digest: new Digest( ValidDigest ) ) + .Canonicalize(); + await Assert.That( canonical.ToString() ).IsEqualTo( $"localhost:5000/myapp@{ValidDigest}" ); + } + + [Test] + public async Task ToStringShowsTagWhenPresent() { + var canonical = new PartialImageRef( + Registry.GitHub, + new Namespace( "myorg" ), + new Repository( "myapp" ), + new Tag( "v1.0" ), + new Digest( ValidDigest ) + ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + await Assert.That( canonical.ToString() ).IsEqualTo( $"ghcr.io/myorg/myapp:v1.0@{ValidDigest}" ); + } + + // ----------------------- + // WithTag + // ----------------------- + [Test] + public async Task WithTagAddsTag() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( Tag.Latest ); + + await Assert.That( updated.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( updated.ToString() ).IsEqualTo( $"docker.io/library/nginx:latest@{ValidDigest}" ); + } + + [Test] + public async Task WithTagReplacesTag() { + var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( new Tag( "alpine" ) ); + + await Assert.That( updated.Tag ).IsEqualTo( new Tag( "alpine" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( $"docker.io/library/nginx:alpine@{ValidDigest}" ); + } + + [Test] + public async Task WithTagPreservesDigest() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( Tag.Latest ); + + await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task WithTagDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + _ = original.With( Tag.Latest ); + + await Assert.That( original.Tag ).IsNull(); + } + + // ----------------------- + // On (change registry) + // ----------------------- + [Test] + public async Task OnChangesRegistry() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( Registry.GitHub ); + + await Assert.That( updated.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( updated.ToString() ).IsEqualTo( $"ghcr.io/library/nginx@{ValidDigest}" ); + } + + [Test] + public async Task OnPreservesDigest() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( Registry.Localhost ); + + await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task OnDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + _ = original.With( Registry.GitHub ); + + await Assert.That( original.Registry ).IsEqualTo( Registry.DockerHub ); + } + + // ----------------------- + // WithNamespace + // ----------------------- + [Test] + public async Task WithNamespaceChangesNamespace() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( new Namespace( "myorg" ) ); + + await Assert.That( updated.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( $"docker.io/myorg/nginx@{ValidDigest}" ); + } + + [Test] + public async Task WithNamespacePreservesOtherProperties() { + var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + var updated = original.With( new Namespace( "custom" ) ); + + await Assert.That( updated.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( updated.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( updated.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task WithNamespaceDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + _ = original.With( new Namespace( "myorg" ) ); + + await Assert.That( original.Namespace ).IsEqualTo( new Namespace( "library" ) ); + } + + // ----------------------- + // Immutability guarantees + // ----------------------- + [Test] + public async Task DigestNeverChanges() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var withTag = original.With( Tag.Latest ); + var withRegistry = original.With( Registry.GitHub ); + var withNamespace = original.With( new Namespace( "myorg" ) ); + + await Assert.That( withTag.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( withRegistry.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( withNamespace.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + // ----------------------- + // Equality + // ----------------------- + [Test] + public async Task EqualReferencesAreEqual() { + var a = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var b = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentDigestsAreNotEqual() { + var a = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var b = new PartialImageRef( "nginx", digest: new Digest( ValidDigest2 ) ).Canonicalize(); + + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task DifferentRegistriesAreNotEqual() { + var a = new PartialImageRef( + Registry.DockerHub, + new Namespace( "library" ), + new Repository( "nginx" ), + new Digest( ValidDigest ) + ) + .Canonicalize(); + var b = new PartialImageRef( + Registry.GitHub, + new Namespace( "library" ), + new Repository( "nginx" ), + new Digest( ValidDigest ) + ) + .Canonicalize(); + + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task SameDigestDifferentTagAreEqual() { + // With default ExcludeTag mode, tags are excluded so these should be equal + var a = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Canonicalize(); + var b = new PartialImageRef( "nginx", new Tag( "alpine" ), digest: new Digest( ValidDigest ) ).Canonicalize(); + + // They should be equal because both have null tags and same digest + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task SameDigestDifferentTagAreNotEqualWhenMaintained() { + // With MaintainTag mode, tags are preserved so these should NOT be equal + var a = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + var b = new PartialImageRef( "nginx", new Tag( "alpine" ), digest: new Digest( ValidDigest ) ) + .Canonicalize( CanonicalizationMode.MaintainTag ); + + // They should NOT be equal because tags are different + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task WithTagCreatesNewInstance() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.With( Tag.Latest ); + + await Assert.That( ReferenceEquals( original, updated ) ).IsFalse(); + } + + // ----------------------- + // Round-trip consistency + // ----------------------- + [Test] + public async Task ParseAndQualifyMaintainsDigest() { + var parsed = PartialImageRef.Parse( $"nginx@{ValidDigest}" ); + var canonical = parsed.Canonicalize(); + + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( canonical.ToString() ).Contains( ValidDigest ); + } + + [Test] + public async Task ToStringCanBeParsedBack() { + var original = new PartialImageRef( + Registry.GitHub, + new Namespace( "myorg" ), + new Repository( "myapp" ), + Tag.Latest, + new Digest( ValidDigest ) + ) + .Canonicalize(); + var str = original.ToString(); + var parsed = PartialImageRef.Parse( str ); + var canonical = parsed.Canonicalize(); + + await Assert.That( canonical.Registry.ToString() ).IsEqualTo( original.Registry.ToString() ); + await Assert.That( canonical.Namespace!.ToString() ).IsEqualTo( original.Namespace!.ToString() ); + await Assert.That( canonical.Repository ).IsEqualTo( original.Repository ); + await Assert.That( canonical.Tag ).IsEqualTo( original.Tag ); + await Assert.That( canonical.Digest ).IsEqualTo( original.Digest ); + await Assert.That( canonical.ToString() ).IsEqualTo( original.ToString() ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/Components/DigestTests.cs b/dotnet/ImageReferences.Tests/Components/DigestTests.cs new file mode 100644 index 0000000..02a1bf7 --- /dev/null +++ b/dotnet/ImageReferences.Tests/Components/DigestTests.cs @@ -0,0 +1,62 @@ +namespace HLabs.ImageReferences.Tests.Components; + +internal sealed class DigestTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + [Test] + public async Task ConstructorSetsValue() { + var digest = new Digest( ValidDigest ); + await Assert.That( digest.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public void EmptyValueThrows() { + Assert.Throws( () => new Digest( string.Empty ) ); + } + + [Test] + public void NullValueThrows() { + Assert.Throws( () => new Digest( null! ) ); + } + + [Test] + public void WhitespaceThrows() { + Assert.Throws( () => new Digest( " " ) ); + } + + [Test] + public void LeadingWhitespaceThrows() { + Assert.Throws( () => new Digest( " sha256:abc" ) ); + } + + [Test] + public void MissingColonThrows() { + Assert.Throws( () => new Digest( "sha256abc" ) ); + } + + [Test] + public async Task ImplicitConversionFromString() { + Digest d = ValidDigest; + await Assert.That( d.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var d = Digest.FromString( ValidDigest ); + await Assert.That( d.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task EqualDigestsAreEqual() { + var a = new Digest( ValidDigest ); + var b = new Digest( ValidDigest ); + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentDigestsAreNotEqual() { + var a = new Digest( ValidDigest ); + var b = new Digest( "sha256:b5ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" ); + await Assert.That( a ).IsNotEqualTo( b ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/Components/NamespaceTests.cs b/dotnet/ImageReferences.Tests/Components/NamespaceTests.cs new file mode 100644 index 0000000..a2a7c0d --- /dev/null +++ b/dotnet/ImageReferences.Tests/Components/NamespaceTests.cs @@ -0,0 +1,41 @@ +namespace HLabs.ImageReferences.Tests.Components; + +internal sealed class NamespaceTests { + [Test] + public async Task ConstructorSetsName() { + var ns = new Namespace( "myteam" ); + await Assert.That( ns.ToString() ).IsEqualTo( "myteam" ); + } + + [Test] + public void EmptyNameThrows() { + Assert.Throws( () => new Namespace( string.Empty ) ); + } + + [Test] + public void NullNameThrows() { + Assert.Throws( () => new Namespace( null! ) ); + } + + [Test] + public void SlashInNameThrows() { + Assert.Throws( () => new Namespace( "a/b" ) ); + } + + [Test] + public void WhitespaceThrows() { + Assert.Throws( () => new Namespace( " team " ) ); + } + + [Test] + public async Task ImplicitConversionFromString() { + Namespace ns = "library"; + await Assert.That( ns.ToString() ).IsEqualTo( "library" ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var ns = Namespace.FromString( "library" ); + await Assert.That( ns.ToString() ).IsEqualTo( "library" ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/Components/RegistryTests.cs b/dotnet/ImageReferences.Tests/Components/RegistryTests.cs new file mode 100644 index 0000000..2231bc5 --- /dev/null +++ b/dotnet/ImageReferences.Tests/Components/RegistryTests.cs @@ -0,0 +1,64 @@ +namespace HLabs.ImageReferences.Tests.Components; + +internal sealed class RegistryTests { + [Test] + public async Task ConstructorSetsHost() { + var registry = new Registry( "my-registry.io" ); + await Assert.That( registry.ToString() ).IsEqualTo( "my-registry.io" ); + } + + [Test] + public void EmptyHostThrows() { + Assert.Throws( () => new Registry( string.Empty ) ); + } + + [Test] + public void WhitespaceHostThrows() { + Assert.Throws( () => new Registry( " " ) ); + } + + [Test] + public void LeadingWhitespaceHostThrows() { + Assert.Throws( () => new Registry( " docker.io" ) ); + } + + [Test] + public async Task ImplicitConversionFromString() { + Registry r = "quay.io"; + await Assert.That( r.ToString() ).IsEqualTo( "quay.io" ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var r = Registry.FromString( "quay.io" ); + await Assert.That( r.ToString() ).IsEqualTo( "quay.io" ); + } + + [Test] + public async Task PredefinedDockerHub() { + await Assert.That( Registry.DockerHub.ToString() ).IsEqualTo( "docker.io" ); + } + + [Test] + public async Task PredefinedGitHub() { + await Assert.That( Registry.GitHub.ToString() ).IsEqualTo( "ghcr.io" ); + } + + [Test] + public async Task AcrFactory() { + var r = Registry.Acr( "mycompany" ); + await Assert.That( r.ToString() ).IsEqualTo( "mycompany.azurecr.io" ); + } + + [Test] + public async Task EcrFactory() { + var r = Registry.Ecr( "123456", "eu-west-1" ); + await Assert.That( r.ToString() ).IsEqualTo( "123456.dkr.ecr.eu-west-1.amazonaws.com" ); + } + + [Test] + public async Task EqualRegistriesAreEqual() { + var a = new Registry( "docker.io", true ); // TODO consider: should it be equal if namespaceRequired is false? + await Assert.That( a ).IsEqualTo( Registry.DockerHub ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/Components/RepositoryTests.cs b/dotnet/ImageReferences.Tests/Components/RepositoryTests.cs new file mode 100644 index 0000000..ac43451 --- /dev/null +++ b/dotnet/ImageReferences.Tests/Components/RepositoryTests.cs @@ -0,0 +1,53 @@ +namespace HLabs.ImageReferences.Tests.Components; + +internal sealed class RepositoryTests { + [Test] + public async Task ConstructorSetsName() { + var repo = new Repository( "nginx" ); + await Assert.That( repo.ToString() ).IsEqualTo( "nginx" ); + } + + [Test] + public void EmptyNameThrows() { + Assert.Throws( () => new Repository( string.Empty ) ); + } + + [Test] + public void NullNameThrows() { + Assert.Throws( () => new Repository( null! ) ); + } + + [Test] + public void SlashInNameThrows() { + Assert.Throws( () => new Repository( "a/b" ) ); + } + + [Test] + public void LeadingWhitespaceThrows() { + Assert.Throws( () => new Repository( " nginx" ) ); + } + + [Test] + public void TrailingWhitespaceThrows() { + Assert.Throws( () => new Repository( "nginx " ) ); + } + + [Test] + public async Task ImplicitConversionFromString() { + Repository r = "myapp"; + await Assert.That( r.ToString() ).IsEqualTo( "myapp" ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var r = Repository.FromString( "myapp" ); + await Assert.That( r.ToString() ).IsEqualTo( "myapp" ); + } + + [Test] + public async Task EqualRepositoriesAreEqual() { + var a = new Repository( "nginx" ); + var b = new Repository( "nginx" ); + await Assert.That( a ).IsEqualTo( b ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/Components/TagTests.cs b/dotnet/ImageReferences.Tests/Components/TagTests.cs new file mode 100644 index 0000000..661d415 --- /dev/null +++ b/dotnet/ImageReferences.Tests/Components/TagTests.cs @@ -0,0 +1,74 @@ +using Semver; + +namespace HLabs.ImageReferences.Tests.Components; + +internal sealed class TagTests { + [Test] + public async Task ConstructorSetsValue() { + var tag = new Tag( "1.0.0" ); + await Assert.That( tag.ToString() ).IsEqualTo( "1.0.0" ); + } + + [Test] + public void EmptyValueThrows() { + Assert.Throws( () => new Tag( string.Empty ) ); + } + + [Test] + public void NullValueThrows() { + Assert.Throws( () => new Tag( null! ) ); + } + + [Test] + public void WhitespaceThrows() { + Assert.Throws( () => new Tag( " latest " ) ); + } + + [Test] + public async Task ImplicitConversionFromString() { + Tag tag = "3.2.1"; + await Assert.That( tag.ToString() ).IsEqualTo( "3.2.1" ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var tag = Tag.FromString( "3.2.1" ); + await Assert.That( tag.ToString() ).IsEqualTo( "3.2.1" ); + } + + [Test] + public async Task ImplicitConversionFromSemVersion() { + Tag tag = new SemVersion( 3, 2, 1 ); + await Assert.That( tag.ToString() ).IsEqualTo( "3.2.1" ); + } + + [Test] + public async Task ExplicitConversionFromSemVersion() { + var tag = Tag.FromSemVersion( new SemVersion( 3, 2, 1 ) ); + await Assert.That( tag.ToString() ).IsEqualTo( "3.2.1" ); + } + + [Test] + public async Task SemVersionMetadataStripped() { + Tag tag = new SemVersion( 1, 0, 0, ["alpha"], ["build42"] ); + await Assert.That( tag.ToString() ).IsEqualTo( "1.0.0-alpha" ); + } + + [Test] + public async Task LatestConstant() { + await Assert.That( Tag.Latest.ToString() ).IsEqualTo( "latest" ); + } + + [Test] + public async Task CustomTagExtension() { + var tag = Tag.Alpha( 3 ); + await Assert.That( tag.ToString() ).IsEqualTo( "alpha-3" ); + } +} + +internal static class TestTagExtensions { + extension( Tag ) { + public static Tag Alpha( uint n ) => new($"alpha-{n}"); + public static Tag Dev => new("dev"); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/ImageIdTests.cs b/dotnet/ImageReferences.Tests/ImageIdTests.cs new file mode 100644 index 0000000..d09093a --- /dev/null +++ b/dotnet/ImageReferences.Tests/ImageIdTests.cs @@ -0,0 +1,194 @@ +namespace HLabs.ImageReferences.Tests; + +internal sealed class ImageIdTests { + private const string ValidHash = "a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + // ----------------------- + // Construction + // ----------------------- + [Test] + public async Task ConstructorWithFullFormat() { + var imageId = new ImageId( ValidDigest ); + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ConstructorWithHashOnly() { + var imageId = new ImageId( ValidHash ); + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ConstructorNormalizesToLowercase() { + var uppercaseHash = "SHA256:A3ED95CAEB02FFE68CDD9FD84406680AE93D633CB16422D00E8A7C22955B46D4"; + var imageId = new ImageId( uppercaseHash ); + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ConstructorWithMixedCaseHash() { + var mixedCase = "A3ED95caeb02FFE68cdd9FD84406680ae93D633cb16422d00e8a7c22955b46d4"; + var imageId = new ImageId( mixedCase ); + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + // ----------------------- + // Validation - Null/Empty/Whitespace + // ----------------------- + [Test] + public void NullValueThrows() { + Assert.Throws( () => new ImageId( null! ) ); + } + + [Test] + public void EmptyValueThrows() { + Assert.Throws( () => new ImageId( string.Empty ) ); + } + + [Test] + public void WhitespaceThrows() { + Assert.Throws( () => new ImageId( " " ) ); + } + + [Test] + public void LeadingWhitespaceThrows() { + Assert.Throws( () => new ImageId( $" {ValidDigest}" ) ); + } + + [Test] + public void TrailingWhitespaceThrows() { + Assert.Throws( () => new ImageId( $"{ValidDigest} " ) ); + } + + // ----------------------- + // Validation - Algorithm + // ----------------------- + [Test] + public void InvalidAlgorithmThrows() { + Assert.Throws( () => new ImageId( $"md5:{ValidHash}" ) ); + } + + [Test] + public void Sha1AlgorithmThrows() { + var sha1Hash = "356a192b7913b04c54574d18c28d46e6395428ab"; // Valid SHA-1 format but wrong algorithm + Assert.Throws( () => new ImageId( $"sha1:{sha1Hash}" ) ); + } + + [Test] + public void Sha512AlgorithmThrows() { + var sha512Hash = + "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"; + Assert.Throws( () => new ImageId( $"sha512:{sha512Hash}" ) ); + } + + // ----------------------- + // Validation - Hash format + // ----------------------- + [Test] + public void InvalidHashLengthTooShortThrows() { + var shortHash = "a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46"; // 62 chars + Assert.Throws( () => new ImageId( shortHash ) ); + } + + [Test] + public void InvalidHashLengthTooLongThrows() { + var longHash = "a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4aa"; // 66 chars + Assert.Throws( () => new ImageId( longHash ) ); + } + + [Test] + public void InvalidHashCharactersThrows() { + var invalidHash = "g3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; // 'g' is not hex + Assert.Throws( () => new ImageId( invalidHash ) ); + } + + [Test] + public void HashWithSpacesThrows() { + var hashWithSpaces = "a3ed95ca eb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + Assert.Throws( () => new ImageId( hashWithSpaces ) ); + } + + [Test] + public void HashWithDashesThrows() { + var hashWithDashes = "a3ed95ca-eb02-ffe6-8cdd-9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + Assert.Throws( () => new ImageId( hashWithDashes ) ); + } + + // ----------------------- + // Conversion + // ----------------------- + [Test] + public async Task ImplicitConversionFromString() { + ImageId imageId = ValidDigest; + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var imageId = ImageId.FromString( ValidDigest ); + await Assert.That( imageId.ToString() ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ToStringReturnsNormalizedFormat() { + var imageId = new ImageId( ValidHash ); + var result = imageId.ToString(); + + await Assert.That( result ).StartsWith( "sha256:" ); + await Assert.That( result ).IsEqualTo( ValidDigest ); + } + + [Test] + public async Task ToStringIncludesAlgorithm() { + var imageId = new ImageId( ValidHash ); + await Assert.That( imageId.ToString() ).Contains( "sha256:" ); + } + + // ----------------------- + // Equality + // ----------------------- + [Test] + public async Task EqualImageIdsAreEqual() { + var a = new ImageId( ValidDigest ); + var b = new ImageId( ValidDigest ); + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentImageIdsAreNotEqual() { + var a = new ImageId( ValidDigest ); + var differentHash = "b5ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + var b = new ImageId( differentHash ); + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task DifferentCasingAreEqual() { + var lowercase = new ImageId( ValidDigest ); + var uppercase = new ImageId( "SHA256:A3ED95CAEB02FFE68CDD9FD84406680AE93D633CB16422D00E8A7C22955B46D4" ); + await Assert.That( lowercase ).IsEqualTo( uppercase ); + } + + [Test] + public async Task WithAndWithoutAlgorithmPrefixAreEqual() { + var withPrefix = new ImageId( ValidDigest ); + var withoutPrefix = new ImageId( ValidHash ); + await Assert.That( withPrefix ).IsEqualTo( withoutPrefix ); + } + + [Test] + public async Task GetHashCodeSameForEqualIds() { + var a = new ImageId( ValidDigest ); + var b = new ImageId( ValidHash ); + await Assert.That( a.GetHashCode() ).IsEqualTo( b.GetHashCode() ); + } + + [Test] + public async Task GetHashCodeDifferentForDifferentIds() { + var a = new ImageId( ValidDigest ); + var differentHash = "b5ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + var b = new ImageId( differentHash ); + await Assert.That( a.GetHashCode() ).IsNotEqualTo( b.GetHashCode() ); + } +} \ No newline at end of file diff --git a/dotnet/Containers.Tests/Containers.Tests.csproj b/dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj similarity index 66% rename from dotnet/Containers.Tests/Containers.Tests.csproj rename to dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj index ffb9963..26e38c0 100644 --- a/dotnet/Containers.Tests/Containers.Tests.csproj +++ b/dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs new file mode 100644 index 0000000..91859c5 --- /dev/null +++ b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs @@ -0,0 +1,314 @@ +using HLabs.ImageReferences.Tests.Components; +using Semver; + +namespace HLabs.ImageReferences.Tests; + +internal sealed class PartialImageReferenceTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + // ----------------------- + // ToString round-trip + // ----------------------- + public static IEnumerable<(PartialImageRef, string)> ToStringCases { + get { + // DockerHub defaults + yield return ( + new PartialImageRef( + Registry.DockerHub, + new Namespace( "library" ), + new Repository( "ubuntu" ), + Tag.Latest + ), + "docker.io/library/ubuntu:latest" + ); + yield return ( + new PartialImageRef( Registry.DockerHub, new Repository( "ubuntu" ), Tag.Latest ), + "docker.io/ubuntu:latest" + ); + yield return ( + new PartialImageRef( new Repository( "ubuntu" ), Tag.Latest ), + "ubuntu:latest" + ); + // No tag — no :latest in output + yield return ( + new PartialImageRef( new Repository( "ubuntu" ) ), + "ubuntu" + ); + yield return ( + new PartialImageRef( "ubuntu" ), + "ubuntu" + ); + // Implicit conversions + yield return ( + new PartialImageRef( Registry.DockerHub, "library", new Repository( "ubuntu" ), Tag.Latest ), + "docker.io/library/ubuntu:latest" + ); + // Custom namespace on DockerHub + yield return ( + new PartialImageRef( new Namespace( "hojmark" ), "drift", Tag.Latest ), + "hojmark/drift:latest" + ); + yield return ( + new PartialImageRef( new Namespace( "hojmark" ), "drift", new SemVersion( 1, 21, 1 ) ), + "hojmark/drift:1.21.1" + ); + yield return ( + new PartialImageRef( Registry.DockerHub, "hojmark", "drift", new SemVersion( 1, 21, 1 ) ), + "docker.io/hojmark/drift:1.21.1" + ); + // Localhost (no namespace) + yield return ( + new PartialImageRef( Registry.Localhost, new Repository( "drift" ), Tag.Latest ), + "localhost:5000/drift:latest" + ); + yield return ( + new PartialImageRef( Registry.Localhost, new Repository( "drift" ), Tag.Dev ), + "localhost:5000/drift:dev" + ); + yield return ( + new PartialImageRef( Registry.Localhost, new Repository( "drift" ), new SemVersion( 2, 0, 0 ) ), + "localhost:5000/drift:2.0.0" + ); + // Other well-known registries + yield return ( + new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ), + "ghcr.io/myorg/myapp:latest" + ); + yield return ( + new PartialImageRef( Registry.Quay, "myorg", "myapp", Tag.Latest ), + "quay.io/myorg/myapp:latest" + ); + // With digest and tag + yield return ( + new PartialImageRef( Registry.DockerHub, "library", "nginx", Tag.Latest, new Digest( ValidDigest ) ), + $"docker.io/library/nginx:latest@{ValidDigest}" + ); + // With digest only (no tag) + yield return ( + new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ), + $"nginx@{ValidDigest}" + ); + } + } + + [Test] + [MethodDataSource( nameof(ToStringCases) )] + public async Task ToStringTest( PartialImageRef reference, string expected ) { + await Assert.That( reference.ToString() ).IsEqualTo( expected ); + } + + // ----------------------- + // Parse round-trip + // ----------------------- + public static IEnumerable<(string, string)> ParseNonCanonicalCases { + get { + // No tag in input → no tag in output + yield return ( "ubuntu", "ubuntu" ); + yield return ( "docker.io/library/ubuntu", "docker.io/library/ubuntu" ); + // Explicit tag preserved + yield return ( "docker.io/library/ubuntu:22.04", "docker.io/library/ubuntu:22.04" ); + yield return ( "docker.io/hojmark/drift:1.21.1", "docker.io/hojmark/drift:1.21.1" ); + yield return ( "ghcr.io/myorg/myapp:latest", "ghcr.io/myorg/myapp:latest" ); + yield return ( "localhost:5000/drift:dev", "localhost:5000/drift:dev" ); + // Digest only + yield return ( $"docker.io/library/nginx@{ValidDigest}", $"docker.io/library/nginx@{ValidDigest}" ); + // Tag + digest + yield return ( $"docker.io/library/nginx:1.25@{ValidDigest}", $"docker.io/library/nginx:1.25@{ValidDigest}" ); + } + } + + [Test] + [MethodDataSource( nameof(ParseNonCanonicalCases) )] + public async Task ParseNonCanonicalTest( string input, string expected ) { + var parsed = PartialImageRef.Parse( input ); + await Assert.That( parsed.ToString() ).IsEqualTo( expected ); + } + + public static IEnumerable<(string, string)> ParseCanonicalCases { + get { + // No tag in input → no tag in output + yield return ( "ubuntu", "ubuntu" ); + yield return ( "docker.io/library/ubuntu", "docker.io/library/ubuntu" ); + // Explicit tag preserved + yield return ( "docker.io/library/ubuntu:22.04", "docker.io/library/ubuntu:22.04" ); + yield return ( "docker.io/hojmark/drift:1.21.1", "docker.io/hojmark/drift:1.21.1" ); + yield return ( "ghcr.io/myorg/myapp:latest", "ghcr.io/myorg/myapp:latest" ); + yield return ( "localhost:5000/drift:dev", "localhost:5000/drift:dev" ); + // Digest only + yield return ( $"docker.io/library/nginx@{ValidDigest}", $"docker.io/library/nginx@{ValidDigest}" ); + // Tag + digest + yield return ( $"docker.io/library/nginx:1.25@{ValidDigest}", $"docker.io/library/nginx:1.25@{ValidDigest}" ); + } + } + + [Test] + [MethodDataSource( nameof(ParseCanonicalCases) )] + public async Task ParseCanonicalTest( string input, string expected ) { + var parsed = input.Image(); + await Assert.That( parsed.ToString() ).IsEqualTo( expected ); + } + + // ----------------------- + // Parse failures + // ----------------------- + [Test] + public void ParseNullThrows() { + Assert.Throws( () => PartialImageRef.Parse( null! ) ); + } + + [Test] + public void ParseEmptyThrows() { + Assert.Throws( () => PartialImageRef.Parse( string.Empty ) ); + } + + // ----------------------- + // TryParse + // ----------------------- + [Test] + public async Task TryParseValidInputReturnsTrue() { + var success = PartialImageRef.TryParse( "docker.io/library/nginx:1.25", out var result ); + await Assert.That( success ).IsTrue(); + await Assert.That( result ).IsNotNull(); + await Assert.That( result.ToString() ).IsEqualTo( "docker.io/library/nginx:1.25" ); + } + + [Test] + public async Task TryParseNullReturnsFalse() { + var success = PartialImageRef.TryParse( null, out var result ); + await Assert.That( success ).IsFalse(); + await Assert.That( result ).IsNull(); + } + + // ----------------------- + // Tag is null when omitted + // ----------------------- + [Test] + public async Task ConstructorWithoutTagLeavesTagNull() { + var image = new PartialImageRef( "nginx" ); + await Assert.That( image.Tag ).IsNull(); + } + + [Test] + public async Task ParseWithoutTagLeavesTagNull() { + var image = PartialImageRef.Parse( "docker.io/library/nginx" ); + await Assert.That( image.Tag ).IsNull(); + } + + // ----------------------- + // Equality (record semantics) + // ----------------------- + [Test] + public async Task EqualReferencesAreEqual() { + var a = new PartialImageRef( "nginx", Tag.Latest ); + var b = new PartialImageRef( "nginx", Tag.Latest ); + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentTagsAreNotEqual() { + var a = new PartialImageRef( "nginx", Tag.Latest ); + var b = new PartialImageRef( "nginx", Tag.Dev ); + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task NullTagAndExplicitTagAreNotEqual() { + var a = new PartialImageRef( "nginx" ); + var b = new PartialImageRef( "nginx", Tag.Latest ); + await Assert.That( a ).IsNotEqualTo( b ); + } + + // ----------------------- + // Construction validation + // ----------------------- + [Test] + public void EmptyRepositoryThrows() { + Assert.Throws( () => new PartialImageRef( new Repository( string.Empty ) ) ); + } + + [Test] + public void WhitespaceRepositoryThrows() { + Assert.Throws( () => new PartialImageRef( new Repository( " " ) ) ); + } + + [Test] + public void RepositoryWithSlashThrows() { + Assert.Throws( () => new PartialImageRef( new Repository( "a/b" ) ) ); + } + + // ----------------------- + // Record `with` expressions + // ----------------------- + [Test] + public async Task WithTagReplacesTag() { + var original = new PartialImageRef( "nginx", new SemVersion( 1, 25, 0 ) ); + var updated = original.With( Tag.Latest ); + await Assert.That( updated.ToString() ).IsEqualTo( "nginx:latest" ); + } + + [Test] + public async Task WithTagNullRemovesTag() { + var original = new PartialImageRef( "nginx", Tag.Latest ); + var updated = original.With( (Tag) null! ); + await Assert.That( updated.Tag ).IsNull(); + await Assert.That( updated.ToString() ).IsEqualTo( "nginx" ); + } + + [Test] + public async Task WithTagPreservesOtherProperties() { + var original = new PartialImageRef( Registry.Localhost, "drift", new SemVersion( 1, 0, 0 ) ); + var updated = original.With( Tag.Latest ); + await Assert.That( updated.Registry ).IsEqualTo( Registry.Localhost ); + await Assert.That( updated.Repository ).IsEqualTo( new Repository( "drift" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( "localhost:5000/drift:latest" ); + } + + [Test] + public async Task WithRegistryReplacesHost() { + var original = new PartialImageRef( new Namespace( "team" ), "myapp", Tag.Latest ); + var updated = original.With( Registry.GitHub ); + await Assert.That( updated.ToString() ).IsEqualTo( "ghcr.io/team/myapp:latest" ); + } + + [Test] + public async Task WithNamespaceReplacesNamespace() { + var original = new PartialImageRef( "drift", Tag.Latest ); + var updated = original.With( new Namespace( "hojmark" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( "hojmark/drift:latest" ); + } + + [Test] + public async Task WithNamespaceNullRemovesNamespace() { + var original = new PartialImageRef( new Namespace( "hojmark" ), "drift", Tag.Latest ); + var updated = original.With( (Namespace) null! ); + await Assert.That( updated.ToString() ).IsEqualTo( "drift:latest" ); + } + + [Test] + public async Task WithDigestAddsDigest() { + var original = new PartialImageRef( "nginx", Tag.Latest ); + var updated = original.With( new Digest( ValidDigest ) ); + await Assert.That( updated.ToString() ).IsEqualTo( $"nginx:latest@{ValidDigest}" ); + } + + [Test] + public async Task WithDigestNullRemovesDigest() { + var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ); + var updated = original.With( (Digest) null! ); + await Assert.That( updated.ToString() ).IsEqualTo( "nginx:latest" ); + } + + [Test] + public async Task WithMultipleProperties() { + var original = new PartialImageRef( "nginx", Tag.Latest ); + var updated = original.With( new SemVersion( 2, 0, 0 ) ).With( Registry.Localhost ).With( (Namespace) null! ); + await Assert.That( updated.ToString() ).IsEqualTo( "localhost:5000/nginx:2.0.0" ); + } + + [Test] + public async Task WithDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", Tag.Latest ); + _ = original.With( new Tag( "alpine" ) ); + await Assert.That( original.Tag ).IsEqualTo( Tag.Latest ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs new file mode 100644 index 0000000..f664abf --- /dev/null +++ b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs @@ -0,0 +1,213 @@ +namespace HLabs.ImageReferences.Tests; + +internal sealed class QualifiedImageRefTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + // ----------------------- + // Construction + // ----------------------- + [Test] + public async Task ConstructWithTagOnly() { + var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + + await Assert.That( image.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( image.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( image.Digest ).IsNull(); + } + + [Test] + public async Task ConstructWithDigestOnly() { + var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Qualify(); + + await Assert.That( image.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( image.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsNull(); + await Assert.That( image.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task ConstructWithTagAndDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Qualify(); + + await Assert.That( image.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( image.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task ConstructWithCustomRegistry() { + var image = new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ).Qualify(); + + await Assert.That( image.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( image.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "myapp" ) ); + } + + [Test] + public async Task ConstructWithoutNamespaceOnNonRequiredRegistry() { + var image = new PartialImageRef( Registry.Localhost, "myapp", Tag.Latest ).Qualify(); + + await Assert.That( image.Registry ).IsEqualTo( Registry.Localhost ); + await Assert.That( image.Namespace ).IsNull(); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "myapp" ) ); + } + + // ----------------------- + // ToString + // ----------------------- + [Test] + public async Task ToStringWithTagOnly() { + var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + await Assert.That( image.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + } + + [Test] + public async Task ToStringWithDigestOnly() { + var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Qualify(); + await Assert.That( image.ToString() ).IsEqualTo( $"docker.io/library/nginx@{ValidDigest}" ); + } + + [Test] + public async Task ToStringWithTagAndDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Qualify(); + await Assert.That( image.ToString() ).IsEqualTo( $"docker.io/library/nginx:latest@{ValidDigest}" ); + } + + [Test] + public async Task ToStringWithCustomRegistry() { + var image = new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ).Qualify(); + await Assert.That( image.ToString() ).IsEqualTo( "ghcr.io/myorg/myapp:latest" ); + } + + [Test] + public async Task ToStringWithoutNamespace() { + var image = new PartialImageRef( Registry.Localhost, "myapp", Tag.Latest ).Qualify(); + await Assert.That( image.ToString() ).IsEqualTo( "localhost:5000/myapp:latest" ); + } + + // ----------------------- + // WithTag + // ----------------------- + [Test] + public async Task WithTagReplacesTag() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var updated = original.With( new Tag( "alpine" ) ); + + await Assert.That( updated.Tag ).IsEqualTo( new Tag( "alpine" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:alpine" ); + } + + [Test] + public async Task WithTagPreservesOtherProperties() { + var original = new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ).Qualify(); + var updated = original.With( new Tag( "v1.0" ) ); + + await Assert.That( updated.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( updated.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( updated.Repository ).IsEqualTo( new Repository( "myapp" ) ); + await Assert.That( updated.Tag ).IsEqualTo( new Tag( "v1.0" ) ); + } + + [Test] + public async Task WithTagDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + _ = original.With( new Tag( "alpine" ) ); + + await Assert.That( original.Tag ).IsEqualTo( Tag.Latest ); + } + + // ----------------------- + // WithRegistry (change registry) + // ----------------------- + [Test] + public async Task WithRegistryChangesRegistry() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var updated = original.With( Registry.GitHub, new Namespace( "myorg" ) ); + + await Assert.That( updated.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( updated.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( "ghcr.io/myorg/nginx:latest" ); + } + + [Test] + public async Task WithRegistryPreservesOtherProperties() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var updated = original.With( Registry.Localhost ); + + await Assert.That( updated.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( updated.Tag ).IsEqualTo( Tag.Latest ); + } + + [Test] + public async Task WithRegistryDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + _ = original.With( Registry.GitHub, new Namespace( "myorg" ) ); + + await Assert.That( original.Registry ).IsEqualTo( Registry.DockerHub ); + } + + // ----------------------- + // Canonicalize + // ----------------------- + [Test] + public async Task CanonicalizeWithDigestCreatesCanonicalRef() { + var qualified = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var canonical = qualified.Canonicalize( new Digest( ValidDigest ) ); + + await Assert.That( canonical ).IsNotNull(); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( canonical.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "nginx" ) ); + } + + [Test] + public async Task CanonicalizeWithExistingDigest() { + var qualified = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Qualify(); + var canonical = qualified.Canonicalize(); + + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public void CanonicalizeWithoutDigestThrows() { + var qualified = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + Assert.Throws( () => qualified.Canonicalize() ); + } + + // ----------------------- + // Equality + // ----------------------- + [Test] + public async Task EqualReferencesAreEqual() { + var a = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var b = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentTagsAreNotEqual() { + var a = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var b = new PartialImageRef( "nginx", new Tag( "alpine" ) ).Qualify(); + + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task DifferentRegistriesAreNotEqual() { + var a = new PartialImageRef( Registry.DockerHub, "library", "nginx", Tag.Latest ).Qualify(); + var b = new PartialImageRef( Registry.GitHub, "library", "nginx", Tag.Latest ).Qualify(); + + await Assert.That( a ).IsNotEqualTo( b ); + } + + [Test] + public async Task DifferentNamespacesAreNotEqual() { + var a = new PartialImageRef( Registry.DockerHub, "library", "nginx", Tag.Latest ).Qualify(); + var b = new PartialImageRef( Registry.DockerHub, "myorg", "nginx", Tag.Latest ).Qualify(); + + await Assert.That( a ).IsNotEqualTo( b ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/StringExtensionsTests.cs b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000..6c65408 --- /dev/null +++ b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs @@ -0,0 +1,175 @@ +namespace HLabs.ImageReferences.Tests; + +internal sealed class StringExtensionsTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + + // ----------------------- + // Image() extension + // ----------------------- + [Test] + public async Task ImageExtensionParsesSimpleReference() { + var image = "nginx:latest".Image(); + + await Assert.That( image ).IsNotNull(); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsEqualTo( Tag.Latest ); + } + + [Test] + public async Task ImageExtensionParsesFullyQualifiedReference() { + var image = "docker.io/library/nginx:1.25".Image(); + + await Assert.That( image.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( image.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsEqualTo( new Tag( "1.25" ) ); + } + + [Test] + public async Task ImageExtensionParsesWithDigest() { + var image = $"nginx@{ValidDigest}".Image(); + + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( image.Tag ).IsNull(); + } + + [Test] + public async Task ImageExtensionParsesWithTagAndDigest() { + var image = $"nginx:latest@{ValidDigest}".Image(); + + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( image.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task ImageExtensionParsesRepositoryOnly() { + var image = "nginx".Image(); + + await Assert.That( image.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( image.Tag ).IsNull(); + await Assert.That( image.Digest ).IsNull(); + } + + [Test] + public void ImageExtensionThrowsOnInvalidFormat() { +#pragma warning disable SA1122 + Assert.Throws( () => "".Image() ); +#pragma warning restore SA1122 + } + + [Test] + public void ImageExtensionThrowsOnNullString() { + string? nullString = null; + // ! Intentionally providing null value + Assert.Throws( () => nullString!.Image() ); + } + + // ----------------------- + // QualifiedImage() extension + // ----------------------- + [Test] + public async Task QualifiedImageExtensionReturnsQualified() { + var qualified = "nginx:latest".QualifiedImage(); + + await Assert.That( qualified ).IsNotNull(); + await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); + } + + [Test] + public async Task QualifiedImageExtensionWorksWithFullyQualified() { + var qualified = "ghcr.io/myorg/myapp:v1.0".QualifiedImage(); + + await Assert.That( qualified.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "myapp" ) ); + await Assert.That( qualified.Tag ).IsEqualTo( new Tag( "v1.0" ) ); + } + + [Test] + public async Task QualifiedImageExtensionWorksWithDigest() { + var qualified = $"nginx@{ValidDigest}".QualifiedImage(); + + await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( qualified.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public void QualifiedImageExtensionThrowsOnInvalid() { +#pragma warning disable SA1122 + Assert.Throws( () => "".QualifiedImage() ); +#pragma warning restore SA1122 + } + + // ----------------------- + // CanonicalImage() extension + // ----------------------- + [Test] + public async Task CanonicalImageExtensionReturnsCanonical() { + var canonical = $"nginx@{ValidDigest}".CanonicalImage(); + + await Assert.That( canonical ).IsNotNull(); + await Assert.That( canonical.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( canonical.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task CanonicalImageExtensionWorksWithFullyQualified() { + var canonical = $"ghcr.io/myorg/myapp@{ValidDigest}".CanonicalImage(); + + await Assert.That( canonical.Registry ).IsEqualTo( Registry.GitHub ); + await Assert.That( canonical.Namespace ).IsEqualTo( new Namespace( "myorg" ) ); + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "myapp" ) ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + } + + [Test] + public async Task CanonicalImageExtensionWorksWithTagAndDigest() { + var canonical = $"nginx:latest@{ValidDigest}".CanonicalImage(); + + await Assert.That( canonical.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + // Tag is excluded by default in canonicalization + await Assert.That( canonical.Tag ).IsNull(); + } + + [Test] + public void CanonicalImageExtensionThrowsWithoutDigest() { + Assert.Throws( () => "nginx:latest".CanonicalImage() ); + } + + [Test] + public void CanonicalImageExtensionThrowsOnInvalidFormat() { + Assert.Throws( () => string.Empty.CanonicalImage() ); + } + + // ----------------------- + // Round-trip consistency + // ----------------------- + [Test] + public async Task ImageExtensionRoundTrips() { + const string input = "docker.io/library/nginx:1.25"; + var image = input.Image(); + await Assert.That( image.ToString() ).IsEqualTo( input ); + } + + [Test] + public async Task QualifiedImageExtensionRoundTrips() { + const string input = "docker.io/library/nginx:latest"; + var qualified = input.QualifiedImage(); + await Assert.That( qualified.ToString() ).IsEqualTo( input ); + } + + [Test] + public async Task CanonicalImageExtensionRoundTrips() { + var input = $"docker.io/library/nginx@{ValidDigest}"; + var canonical = input.CanonicalImage(); + await Assert.That( canonical.ToString() ).IsEqualTo( input ); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/AGENTS.md b/dotnet/ImageReferences/AGENTS.md new file mode 100644 index 0000000..6becd8e --- /dev/null +++ b/dotnet/ImageReferences/AGENTS.md @@ -0,0 +1,75 @@ +## Project overview + +A .NET library for strongly-typed, validated container image references (Docker/OCI). +Parses, builds, and manipulates image references like `docker.io/library/nginx:1.25`. + +## Architecture + +### Technology stack + +- **Platform**: .NET 10, C# 14, NuGet CPM +- **Build system**: [NUKE](https://nuke.build/) +- **Packaging**: NuGet +- **Testing**: TUnit +- **Continuous Integration**: GitHub Actions + +### Project structure + +``` +dotnet/ +├── Containers/ # Main library project for image reference parsing and manipulation +├── Containers.Extensions.Nuke/ # Use image references seamlessly with NUKE +├─ .Tests/ # Tests related to a project +... (more) +``` + +## Development guidelines + +### C# coding + +- Nullable reference types enabled (`enable`) +- Null-forgiving operator (`!`) should be avoided and if used, should be justified with a comment +- If a csproj file has `true`, then it must also have: + - ``, `` and a README.md included +- Image reference component's string representation should be lowercase. You may suppress CA1308 to achieve this. + +### Architecture principles + +- Immutable value types for image reference components (registry, repository, tag, digest, etc.) +- No external dependencies for core functionality (keep it lean) +- Avoid string concatenation for building references +- Follow OCI standards but allow for widely used Docker-specific conventions +- Stable public API surface. You must warn me if you change it. + +### Error handling + +- Specific exception types: `InvalidTagException`, etc. +- Validate inputs early, fail fast with clear error messages +- Methods must document exceptions that can be thrown + +### Testing requirements + +- Unit tests for all parsers (valid and invalid inputs) +- Test edge cases: max lengths, special characters, case sensitivity +- Test OCI spec examples from official documentation +- Test Docker-specific conventions +- Round-trip tests: Parse(ToString(x)) == x +- Coverage target: 98%+ +- Follow AAA pattern (Arrange, Act, Assert) +- One assertion focus per test method, although multiple assertions are allowed +- Don't run specific tests using filters. Instead run the whole test suite – it runs very quickly. + +### Documentation + +- Keep usage examples up to date in project README.md +- XML docs on all public APIs with ``, ``, ``, `` being required. + - Do not add `` if the exception is thrown because of null argumment and the argument is non-nullable. + - `private`, `internal` and `private protected` types/methods do not need XML docs but should still be + well-commented if their logic is complex. +- Include `` tags showing valid/invalid inputs where appropriate. + - A sample digest or image ID (being a sha256 hash) value should be "sha256:abc123" +- Reference OCI spec sections where applicable +- Document format specifications clearly +- Keep documentation up to date with code changes +- Use Markdown for README and other documentation files +- Use "container image" over "Docker image" \ No newline at end of file diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs new file mode 100644 index 0000000..9178038 --- /dev/null +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -0,0 +1,94 @@ +namespace HLabs.ImageReferences; + +/// +/// Content-addressable (immutable) image reference. +/// Guaranteed to resolve to the same image content due to digest pinning. +/// +public sealed record CanonicalImageRef : ImageRef { + /// + /// Gets the registry. + /// + public new Registry Registry { + get; + } + + /// + /// Gets the repository. + /// + public new Repository Repository { + get; + } + + /// + /// Gets the digest. + /// + public new Digest Digest { + get; + } + + internal CanonicalImageRef( + Registry registry, + Namespace? ns, + Repository repository, + Digest digest, + Tag? tag = null + ) { + Registry = registry ?? throw new ArgumentNullException( nameof(registry) ); + Namespace = ns ?? ( Registry.NamespaceRequired ? throw new ArgumentNullException( nameof(ns) ) : null ); + Repository = repository ?? throw new ArgumentNullException( nameof(repository) ); + Digest = digest ?? throw new ArgumentNullException( nameof(digest) ); + Tag = tag; // Cosmetic + } + + /// + /// Returns a new instance with the specified tag. + /// + /// The tag. + /// A new with the specified tag. + public CanonicalImageRef With( Tag? tag ) => + new(Registry, Namespace, Repository, Digest, tag); + + /// + /// Returns a new instance with the specified digest. + /// + /// The digest. + /// A new with the specified digest. + public CanonicalImageRef With( Digest digest ) => + new(Registry, Namespace, Repository, digest, Tag); + + /// + /// Returns a new instance with the specified registry. + /// + /// The registry. + /// A new with the specified registry. + public CanonicalImageRef With( Registry registry ) => + new(registry, Namespace, Repository, Digest, Tag); + + /// + /// Returns a new instance with the specified registry and namespace. + /// + /// The registry. + /// The namespace. + /// A new with the specified registry and namespace. + public CanonicalImageRef With( Registry registry, Namespace ns ) => + new(registry, ns, Repository, Digest, Tag); + + /// + /// Returns a new instance with the specified namespace. + /// + /// The namespace. + /// A new with the specified namespace. + public CanonicalImageRef With( Namespace ns ) => + new(Registry, ns, Repository, Digest, Tag); + + /// + /// Returns the string representation of this canonical image reference. + /// + /// A string representation of this canonical image reference. + public override string ToString() { + var nsPart = Namespace is not null ? $"{Namespace}/" : string.Empty; + return Tag is not null + ? $"{Registry}/{nsPart}{Repository}:{Tag}@{Digest}" + : $"{Registry}/{nsPart}{Repository}@{Digest}"; + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/CanonicalizationMode.cs b/dotnet/ImageReferences/CanonicalizationMode.cs new file mode 100644 index 0000000..644c1fd --- /dev/null +++ b/dotnet/ImageReferences/CanonicalizationMode.cs @@ -0,0 +1,18 @@ +namespace HLabs.ImageReferences; + +/// +/// Specifies how to handle the tag when canonicalizing an image reference. +/// +public enum CanonicalizationMode { + /// + /// Excludes the tag from the canonical reference. + /// The canonical reference will only include the digest. + /// + ExcludeTag, + + /// + /// Maintains the tag in the canonical reference as a cosmetic label. + /// The canonical reference will include both tag and digest. + /// + MaintainTag +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Digest.cs b/dotnet/ImageReferences/Components/Digest.cs new file mode 100644 index 0000000..b9f24ca --- /dev/null +++ b/dotnet/ImageReferences/Components/Digest.cs @@ -0,0 +1,93 @@ +using System.Text.RegularExpressions; + +namespace HLabs.ImageReferences; + +/// +/// Represents a content-addressable digest for a container image (e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4). +/// Digests are immutable identifiers that uniquely identify image content. +/// +/// +/// +/// var digest = new Digest("sha256:abc123"); +/// Digest digest = "sha256:abc123"; // Implicit conversion from string +/// Digest digest = "abc123"; // Algorithm prefix optional +/// +/// +public sealed record Digest { + private const string DefaultAlgorithm = "sha256"; + + private static readonly Regex Sha256Regex = + new(@"^[0-9a-fA-F]{64}$", RegexOptions.Compiled); + + private string Value { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The digest value. Can be in format sha256:abc123 or just abc123. + /// Thrown when is an invalid digest. + public Digest( string value ) { + if ( string.IsNullOrWhiteSpace( value ) ) { + throw new ArgumentException( "Digest cannot be null or empty", nameof(value) ); + } + + if ( value.Trim().Length != value.Length ) { + throw new ArgumentException( "Digest contains leading/trailing whitespace", nameof(value) ); + } + + string digest; + + if ( value.Contains( ':', StringComparison.Ordinal ) ) { + var parts = value.Split( ':', 2 ); + + var algorithm = parts[0]; + digest = parts[1]; + + if ( !algorithm.Equals( DefaultAlgorithm, StringComparison.OrdinalIgnoreCase ) ) { + throw new ArgumentException( + $"Unsupported digest algorithm '{algorithm}'. Only sha256 is supported.", + nameof(value) + ); + } + } + else { + digest = value; + } + + if ( !Sha256Regex.IsMatch( digest ) ) { + throw new ArgumentException( + "Image ID digest must be a valid 64-character hexadecimal SHA-256 hash.", + nameof(value) + ); + } + + // Conventionally lowercase +#pragma warning disable CA1308 + Value = $"{DefaultAlgorithm}:{digest.ToLowerInvariant()}"; +#pragma warning restore CA1308 + } + + /// + /// Implicitly converts a string to a . + /// + /// The digest value. + public static implicit operator Digest( string value ) => FromString( value ); + + /// + /// Creates a from a string value. + /// + /// The digest value. + /// A new instance. + /// Thrown when is an invalid digest. + public static Digest FromString( string value ) { + return new(value); + } + + /// + /// Returns the string representation of this digest. + /// + /// The digest in format sha256:hash. + public override string ToString() => Value; +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Namespace.Instances.cs b/dotnet/ImageReferences/Components/Namespace.Instances.cs new file mode 100644 index 0000000..381ba4a --- /dev/null +++ b/dotnet/ImageReferences/Components/Namespace.Instances.cs @@ -0,0 +1,8 @@ +namespace HLabs.ImageReferences; + +public sealed partial record Namespace { + /// + /// Default DockerHub namespace. + /// + internal static readonly Namespace Library = new("library"); +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Namespace.cs b/dotnet/ImageReferences/Components/Namespace.cs new file mode 100644 index 0000000..efae520 --- /dev/null +++ b/dotnet/ImageReferences/Components/Namespace.cs @@ -0,0 +1,72 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HLabs.ImageReferences; + +/// +/// Represents a namespace within a container registry (e.g., organization or user name). +/// Namespaces are used to organize repositories within a registry. +/// +/// +/// +/// var ns = new Namespace("library"); // DockerHub official images +/// var ns = new Namespace("myorg"); // Organization namespace +/// var ns = new Namespace("username"); // User namespace +/// Namespace ns = "myorg"; // Implicit conversion from string +/// +/// +[SuppressMessage( + "Naming", + "CA1716:Identifiers should not match keywords", + Justification = "Implicit construction will likely be common and usage will otherwise be minimally verbose" +)] +public sealed partial record Namespace { + private string Name { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The namespace name. + /// Thrown when is an invalid namespace. + public Namespace( string name ) { + if ( string.IsNullOrWhiteSpace( name ) ) { + throw new ArgumentException( "Namespace cannot be null or empty", nameof(name) ); + } + + if ( name.Contains( '/', StringComparison.Ordinal ) ) { + throw new ArgumentException( "Namespace cannot contain '/'", nameof(name) ); + } + + if ( name.Trim().Length != name.Length ) { + throw new ArgumentException( "Namespace contains leading/trailing whitespace", nameof(name) ); + } + + // Namespace names are conventionally lowercase +#pragma warning disable CA1308 + Name = name.ToLowerInvariant(); +#pragma warning restore CA1308 + } + + /// + /// Implicitly converts a string to a . + /// + /// The namespace name. + public static implicit operator Namespace( string value ) => FromString( value ); + + /// + /// Returns the string representation of this namespace. + /// + /// The namespace name in lowercase. + public override string ToString() => Name; + + /// + /// Creates a from a string value. + /// + /// The namespace name. + /// A new instance. + /// Thrown when value is invalid. + public static Namespace FromString( string value ) { + return new(value); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Registry.Instances.cs b/dotnet/ImageReferences/Components/Registry.Instances.cs new file mode 100644 index 0000000..52d300c --- /dev/null +++ b/dotnet/ImageReferences/Components/Registry.Instances.cs @@ -0,0 +1,53 @@ +namespace HLabs.ImageReferences; + +public sealed partial record Registry { + /// + /// Docker Hub registry (docker.io). + /// + public static readonly Registry DockerHub = new("docker.io", true); + + /// + /// Quay.io container registry (quay.io). + /// + public static readonly Registry Quay = new("quay.io", true); + + /// + /// GitHub Container Registry (ghcr.io). + /// + public static readonly Registry GitHub = new("ghcr.io", true); + + /// + /// Local registry (localhost:5000). + /// + public static readonly Registry Localhost = new("localhost:5000", false); + + /// + /// Azure Container Registry. + /// + /// Registry name. + /// The custom ACR registry. + /// + /// + /// Acr("myregistry"); // myregistry.azurecr.io + /// + /// + public static Registry Acr( string name ) => new($"{name}.azurecr.io"); + + /// + /// Amazon ECR private registry. + /// + /// Amazon AWS account ID. + /// The region. + /// The custom ECR registry. + /// + /// + /// Ecr("aws_account_id", "region"); // aws_account_id.dkr.ecr.region.amazonaws.com + /// + /// + public static Registry Ecr( string accountId, string region ) { + ArgumentException.ThrowIfNullOrWhiteSpace( accountId ); + ArgumentException.ThrowIfNullOrWhiteSpace( region ); + + return new($"{accountId}.dkr.ecr.{region}.amazonaws.com"); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Registry.cs b/dotnet/ImageReferences/Components/Registry.cs new file mode 100644 index 0000000..70a7712 --- /dev/null +++ b/dotnet/ImageReferences/Components/Registry.cs @@ -0,0 +1,77 @@ +namespace HLabs.ImageReferences; + +/// +/// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). +/// +/// +/// +/// var registry = new Registry("docker.io"); +/// var registry = new Registry("localhost:5000"); +/// Registry registry = "ghcr.io"; // Implicit conversion from string +/// +/// +public sealed partial record Registry { + private string Host { + get; + } + + internal bool NamespaceRequired { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry host (e.g., "docker.io", "ghcr.io"). Cannot be null, empty, or contain whitespace. + /// Indicates whether this registry requires a namespace for image references. + /// Thrown when host is null, empty, or contains leading/trailing whitespace. + public Registry( string host, bool namespaceRequired = false ) { + if ( string.IsNullOrWhiteSpace( host ) ) { + throw new ArgumentException( "Registry host cannot be null or empty", nameof(host) ); + } + + if ( host.Trim().Length != host.Length ) { + throw new ArgumentException( "Registry host contains leading/trailing whitespace", nameof(host) ); + } + + // Registry hosts are conventionally lowercase +#pragma warning disable CA1308 + Host = host.ToLowerInvariant(); +#pragma warning restore CA1308 + NamespaceRequired = namespaceRequired; + } + + /// + /// Implicitly converts a string to a . + /// + /// The registry host. + public static implicit operator Registry( string host ) => FromString( host ); + + /// + /// Creates a from a string value. + /// + /// The registry host. + /// A new instance. + /// Thrown when host is invalid. + public static Registry FromString( string host ) { + return new(host); + } + + /// + /// Returns the string representation of this registry. + /// + /// The registry host in lowercase. + public override string ToString() => Host; + + /// + public bool Equals( Registry? other ) { + return other is not null && Host == other.Host; + } + + /// + public override int GetHashCode() { + #pragma warning disable CA1307 + return Host.GetHashCode(); +#pragma warning restore CA1307 + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Repository.cs b/dotnet/ImageReferences/Components/Repository.cs new file mode 100644 index 0000000..cc842ab --- /dev/null +++ b/dotnet/ImageReferences/Components/Repository.cs @@ -0,0 +1,64 @@ +namespace HLabs.ImageReferences; + +/// +/// Represents a repository name within a container registry namespace. +/// A repository is the name of the container image (e.g., "nginx", "ubuntu", "myapp"). +/// +/// +/// +/// var repo = new Repository("nginx"); +/// var repo = new Repository("myapp"); +/// Repository repo = "ubuntu"; // Implicit conversion from string +/// +/// +public sealed record Repository { + private string Name { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The repository name. Cannot be null, empty, contain forward slashes, or have whitespace. + /// Thrown when name is null, empty, contains '/', or contains leading/trailing whitespace. + public Repository( string name ) { + if ( string.IsNullOrWhiteSpace( name ) ) { + throw new ArgumentException( "Repository cannot be null or empty", nameof(name) ); + } + + if ( name.Contains( '/', StringComparison.Ordinal ) ) { + throw new ArgumentException( "Repository cannot contain '/'", nameof(name) ); + } + + if ( name.Trim().Length != name.Length ) { + throw new ArgumentException( "Repository contains leading/trailing whitespace", nameof(name) ); + } + + // Repository names are conventionally lowercase +#pragma warning disable CA1308 + Name = name.ToLowerInvariant(); +#pragma warning restore CA1308 + } + + /// + /// Implicitly converts a string to a . + /// + /// The repository name. + public static implicit operator Repository( string value ) => FromString( value ); + + /// + /// Creates a from a string value. + /// + /// The repository name. + /// A new instance. + /// Thrown when value is invalid. + public static Repository FromString( string value ) { + return new(value); + } + + /// + /// Returns the string representation of this repository. + /// + /// The repository name in lowercase. + public override string ToString() => Name; +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Tag.Instances.cs b/dotnet/ImageReferences/Components/Tag.Instances.cs new file mode 100644 index 0000000..44b775a --- /dev/null +++ b/dotnet/ImageReferences/Components/Tag.Instances.cs @@ -0,0 +1,8 @@ +namespace HLabs.ImageReferences; + +public sealed partial record Tag { + /// + /// Represents the "latest" tag, commonly used as the default tag for container images. + /// + public static readonly Tag Latest = new("latest"); +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Tag.cs b/dotnet/ImageReferences/Components/Tag.cs new file mode 100644 index 0000000..028279f --- /dev/null +++ b/dotnet/ImageReferences/Components/Tag.cs @@ -0,0 +1,81 @@ +using Semver; + +namespace HLabs.ImageReferences; + +/// +/// Represents a tag for a container image (e.g., "latest", "1.0", "v2.3.1"). +/// Tags are mutable references that can point to different images over time. +/// +/// +/// +/// var tag = new Tag("latest"); +/// var tag = new Tag("v1.2.3"); +/// Tag tag = "alpine"; // Implicit conversion from string +/// +/// +public sealed partial record Tag { + private string Value { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The tag value. Cannot be null, empty, or contain whitespace. + /// Thrown when value is null, empty, or contains leading/trailing whitespace. + public Tag( string value ) { + if ( string.IsNullOrWhiteSpace( value ) ) { + throw new ArgumentException( "Tag cannot be null or empty", nameof(value) ); + } + + if ( value.Trim().Length != value.Length ) { + throw new ArgumentException( "Tag contains leading/trailing whitespace", nameof(value) ); + } + + // Tags are conventionally lowercase +#pragma warning disable CA1308 + Value = value.ToLowerInvariant(); +#pragma warning restore CA1308 + } + + /// + /// Implicitly converts a string to a . + /// + /// The tag value. + public static implicit operator Tag( string tag ) => FromString( tag ); + + /// + /// Implicitly converts a to a . + /// The version is converted to string format without metadata. + /// + /// The semantic version. + public static implicit operator Tag( SemVersion v ) => FromSemVersion( v ); + + /// + /// Creates a from a string value. + /// + /// The tag value. + /// A new instance. + /// Thrown when tag is invalid. + public static Tag FromString( string tag ) { + return new(tag); + } + + /// + /// Creates a from a semantic version. + /// The version is converted to string format without metadata (e.g., "1.2.3"). + /// + /// The semantic version. + /// A new instance. + /// Thrown when v is null. + public static Tag FromSemVersion( SemVersion v ) { + ArgumentNullException.ThrowIfNull( v ); + return new(v.WithoutMetadata().ToString()); + } + + /// + /// Returns the string representation of this tag. + /// + /// The tag value in lowercase. + public override string ToString() => Value; +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs b/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs new file mode 100644 index 0000000..ed3d813 --- /dev/null +++ b/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs @@ -0,0 +1,87 @@ +namespace HLabs.ImageReferences; + +/// +/// Extension methods for that provide convenient qualification and canonicalization. +/// +public static class PartialImageRefExtensions { +#pragma warning disable CA1034 + extension( PartialImageRef imageRef ) { +#pragma warning restore CA1034 + // Single component overloads + + /// + /// Qualifies the image reference with the specified registry. + /// + /// The registry. + /// A qualified image reference. + public QualifiedImageRef Qualify( Registry? registry ) => + imageRef.With( registry ).Qualify(); + + /// + /// Qualifies the image reference with the specified tag. + /// + /// The tag. + /// A qualified image reference. + public QualifiedImageRef Qualify( Tag? tag ) => + imageRef.With( tag ).Qualify(); + + // Multi-component overloads + + /// + /// Qualifies the image reference with the specified registry and namespace. + /// + /// The registry. + /// The namespace. + /// A qualified image reference. + public QualifiedImageRef Qualify( Registry registry, Namespace ns ) => + imageRef.With( registry, ns ).Qualify(); + + /// + /// Qualifies the image reference with the specified registry and tag. + /// + /// The registry. + /// The tag. + /// A qualified image reference. + public QualifiedImageRef Qualify( Registry registry, Tag tag ) => + imageRef.With( registry ).With( tag ).Qualify(); + + /// + /// Qualifies the image reference with the specified registry, namespace, and tag. + /// + /// The registry. + /// The namespace. + /// The tag. + /// A qualified image reference. + public QualifiedImageRef Qualify( Registry registry, Namespace ns, Tag tag ) => + imageRef.With( registry, ns ).With( tag ).Qualify(); + + // Canonicalization overloads + + /// + /// Canonicalizes the image reference with the specified digest. + /// + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Digest digest ) => + imageRef.With( digest ).Qualify().Canonicalize(); + + /// + /// Canonicalizes the image reference with the specified registry and digest. + /// + /// The registry. + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Registry registry, Digest digest ) => + imageRef.With( registry ).With( digest ).Qualify().Canonicalize(); + + /// + /// Canonicalizes the image reference with the specified registry, namespace, and digest. + /// + /// The registry. + /// The namespace. + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Registry registry, Namespace ns, Digest digest ) => + imageRef.With( registry, ns ).With( digest ).Qualify().Canonicalize(); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs b/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs new file mode 100644 index 0000000..5d480c4 --- /dev/null +++ b/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs @@ -0,0 +1,37 @@ +namespace HLabs.ImageReferences; + +/// +/// Extension methods for that provide convenient canonicalization. +/// +public static class QualifiedImageRefExtensions { +#pragma warning disable CA1034 + extension( QualifiedImageRef imageRef ) { +#pragma warning restore CA1034 + /// + /// Canonicalizes the image reference with the specified digest. + /// + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Digest digest ) => + imageRef.With( digest ).Canonicalize(); + + /// + /// Canonicalizes the image reference with the specified registry and digest. + /// + /// The registry. + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Registry registry, Digest digest ) => + imageRef.With( registry ).With( digest ).Canonicalize(); + + /// + /// Canonicalizes the image reference with the specified registry, namespace, and digest. + /// + /// The registry. + /// The namespace. + /// The digest. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( Registry registry, Namespace ns, Digest digest ) => + imageRef.With( registry, ns ).With( digest ).Canonicalize(); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/ImageId.cs b/dotnet/ImageReferences/ImageId.cs new file mode 100644 index 0000000..77c7d7c --- /dev/null +++ b/dotnet/ImageReferences/ImageId.cs @@ -0,0 +1,93 @@ +using System.Text.RegularExpressions; + +namespace HLabs.ImageReferences; + +/// +/// Represents a local container image ID (e.g., "sha256:a3ed95caeb02..."). +/// Image IDs are used to uniquely identify images in the local Docker daemon. +/// +/// +/// +/// var imageId = new ImageId("sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"); +/// var imageId = new ImageId("a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"); // Algorithm prefix optional +/// ImageId imageId = "sha256:abc..."; // Implicit conversion from string +/// +/// +public sealed record ImageId { + private const string DefaultAlgorithm = "sha256"; + + private static readonly Regex Sha256Regex = + new(@"^[0-9a-fA-F]{64}$", RegexOptions.Compiled); + + private string Value { + get; + } + + /// + /// Initializes a new instance of the class. + /// + /// The image ID value. Can be in format "sha256:hash" or just "hash". Hash must be a valid 64-character hexadecimal SHA-256. + /// Thrown when value is null, empty, contains whitespace, uses an unsupported algorithm, or has an invalid hash format. + public ImageId( string value ) { + if ( string.IsNullOrWhiteSpace( value ) ) { + throw new ArgumentException( "Image ID cannot be null or empty", nameof(value) ); + } + + if ( value.Trim().Length != value.Length ) { + throw new ArgumentException( "Image ID contains leading/trailing whitespace", nameof(value) ); + } + + string digest; + + if ( value.Contains( ':', StringComparison.Ordinal ) ) { + var parts = value.Split( ':', 2 ); + + var algorithm = parts[0]; + digest = parts[1]; + + if ( !algorithm.Equals( DefaultAlgorithm, StringComparison.OrdinalIgnoreCase ) ) { + throw new ArgumentException( + $"Unsupported image ID algorithm '{algorithm}'. Only sha256 is supported.", + nameof(value) + ); + } + } + else { + digest = value; + } + + if ( !Sha256Regex.IsMatch( digest ) ) { + throw new ArgumentException( + "Image ID digest must be a valid 64-character hexadecimal SHA-256 hash.", + nameof(value) + ); + } + + // Conventionally lowercase +#pragma warning disable CA1308 + Value = $"{DefaultAlgorithm}:{digest.ToLowerInvariant()}"; +#pragma warning restore CA1308 + } + + /// + /// Implicitly converts a string to an . + /// + /// The image ID value. + public static implicit operator ImageId( string value ) => FromString( value ); + + /// + /// Creates an from a string value. + /// + /// The image ID value. + /// A new instance. + /// Thrown when value is invalid. + public static ImageId FromString( string value ) { + return new(value); + } + + /// + /// Returns the string representation of this image ID. + /// + /// The image ID in format "sha256:hash" with lowercase hash. + public override string ToString() => Value; +} \ No newline at end of file diff --git a/dotnet/ImageReferences/ImageRef.cs b/dotnet/ImageReferences/ImageRef.cs new file mode 100644 index 0000000..8440de3 --- /dev/null +++ b/dotnet/ImageReferences/ImageRef.cs @@ -0,0 +1,54 @@ +namespace HLabs.ImageReferences; + +/// +/// Base class for container image references. +/// Provides common properties for registry, namespace, repository, tag, and digest. +/// +public abstract record ImageRef { + /// + /// Gets the registry where the image is hosted (e.g., docker.io, ghcr.io). + /// May be null for partial references. + /// + public Registry? Registry { + get; + protected init; + } + +#pragma warning disable CA1716 + /// + /// Gets the namespace within the registry (e.g., organization or username). + /// May be null for partial references or registries that don't require namespaces. + /// + public Namespace? Namespace { +#pragma warning restore CA1716 + get; + protected init; + } + + /// + /// Gets the repository name (e.g., nginx, ubuntu, myapp). + /// May be null for partial references. + /// + public Repository? Repository { + get; + protected init; + } + + /// + /// Gets the tag that identifies a version or variant (e.g., latest, v1.0, alpine). + /// May be null for partial references or qualified references with a digest. + /// + public Tag? Tag { + get; + protected init; + } + + /// + /// Gets the content-addressable digest that uniquely identifies image content. + /// May be null for partial references or qualified references with a tag. + /// + public Digest? Digest { + get; + protected init; + } +} \ No newline at end of file diff --git a/dotnet/Containers/Containers.csproj b/dotnet/ImageReferences/ImageReferences.csproj similarity index 61% rename from dotnet/Containers/Containers.csproj rename to dotnet/ImageReferences/ImageReferences.csproj index 7bf9d8f..841aaff 100644 --- a/dotnet/Containers/Containers.csproj +++ b/dotnet/ImageReferences/ImageReferences.csproj @@ -1,8 +1,8 @@  - Describe, validate, and manipulate container image references - Docker container containers + Strongly-typed container image references. Build, parse and manipulate. + Docker Podman container containers image true diff --git a/dotnet/ImageReferences/Parsing/StringExtensions.cs b/dotnet/ImageReferences/Parsing/StringExtensions.cs new file mode 100644 index 0000000..79d46f8 --- /dev/null +++ b/dotnet/ImageReferences/Parsing/StringExtensions.cs @@ -0,0 +1,41 @@ +namespace HLabs.ImageReferences; + +/// +/// Extension methods for working with container image references from strings. +/// +public static class StringExtensions { +#pragma warning disable CA1034 + extension( string imageReference ) { +#pragma warning restore CA1034 + /// + /// Creates a partial container image reference from a string. + /// The string is parsed to extract registry, namespace, repository, tag, and digest components. + /// + /// A parsed from the string. + /// Thrown when the image reference string is invalid. + /// + /// + /// var image = "nginx:latest".Image(); + /// var image = "docker.io/library/nginx:1.25".Image(); + /// + /// + public PartialImageRef Image() => PartialImageRef.Parse( imageReference ); + + /// + /// Creates a qualified container image reference from a string. + /// + /// A . + /// Thrown when the image reference string is invalid. + /// Thrown if the image reference cannot be qualified. + public QualifiedImageRef QualifiedImage() => PartialImageRef.Parse( imageReference ).Qualify(); + + /// + /// Creates a canonical container image reference from a string. + /// This is only possible if the image reference has a digest. + /// + /// A . + /// Thrown when the image reference string is invalid. + /// Thrown if the image reference cannot be canonicalized. + public CanonicalImageRef CanonicalImage() => PartialImageRef.Parse( imageReference ).Canonicalize(); + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/PartialImageRef.Parsing.cs b/dotnet/ImageReferences/PartialImageRef.Parsing.cs new file mode 100644 index 0000000..dd6a7af --- /dev/null +++ b/dotnet/ImageReferences/PartialImageRef.Parsing.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; + +namespace HLabs.ImageReferences; + +public sealed partial record PartialImageRef { + [GeneratedRegex( + @"^(?:(?[^/]+?)/)?(?:(?[^/]+?)/)?(?[^:@]+)(?::(?[^@]+))?(?:@(?.+))?$", + RegexOptions.CultureInvariant + )] + private static partial Regex ImageRefRegex(); + + /// + /// Parses a string representation of an image reference. + /// + /// The string to parse. + /// A parsed . + /// Thrown when imageReference is null. + /// Thrown when imageReference is not in a valid format. + public static PartialImageRef Parse( string imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + + var match = ImageRefRegex().Match( imageReference.Trim() ); + if ( !match.Success ) { + throw new FormatException( $"Invalid image reference: '{imageReference}'" ); + } + + var registry = string.IsNullOrEmpty( match.Groups["registry"].Value ) + ? null + : new Registry( match.Groups["registry"].Value ); + var @namespace = string.IsNullOrEmpty( match.Groups["namespace"].Value ) + ? null + : new Namespace( match.Groups["namespace"].Value ); + var repository = new Repository( match.Groups["repository"].Value ); + var tag = string.IsNullOrEmpty( match.Groups["tag"].Value ) ? null : new Tag( match.Groups["tag"].Value ); + var digest = string.IsNullOrEmpty( match.Groups["digest"].Value ) + ? null + : new Digest( match.Groups["digest"].Value ); + + return new PartialImageRef( repository, tag, registry, @namespace, digest ); + } + + /// + /// Tries to parse a string representation of an image reference. + /// + /// The string to parse. + /// When this method returns, contains the parsed reference if parsing succeeded, or null if it failed. + /// true if parsing succeeded; otherwise, false. + public static bool TryParse( string? input, out PartialImageRef? reference ) { + try { + if ( input is null ) { + reference = null; + return false; + } + + reference = Parse( input ); + return true; + } + // Justification: ok for TryParse pattern +#pragma warning disable CA1031 + catch { +#pragma warning restore CA1031 + reference = null; + return false; + } + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs new file mode 100644 index 0000000..4bb16c3 --- /dev/null +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -0,0 +1,312 @@ +namespace HLabs.ImageReferences; + +// TODO support platform +// TODO add no-arg Canonicalize? +/// +/// Partial image reference that may have some components unspecified. +/// +/// +/// Use to convert to a fully qualified reference. +/// +/// +/// Use to convert to a canonical reference. +/// +/// +/// +public sealed partial record PartialImageRef : ImageRef { + private PartialImageRef( + Repository? repository, +#pragma warning disable S3427 + Tag? tag = null, +#pragma warning restore S3427 + Registry? registry = null, + Namespace? @namespace = null, + Digest? digest = null + ) { + Repository = repository; + Tag = tag; + Registry = registry; + Namespace = @namespace; + Digest = digest; + } + +#pragma warning disable SA1124 + + #region DockerHub + +#pragma warning restore SA1124 + + /// + /// Initializes a new instance of the class. + /// + /// The repository name. + public PartialImageRef( Repository repository ) + : this( repository, null, null, null, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The repository name. + /// The tag. + public PartialImageRef( Repository repository, Tag tag ) + : this( repository, tag, null, null, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The namespace. + /// The repository name. + public PartialImageRef( Namespace ns, Repository repository ) + : this( repository, null, null, ns, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The namespace. + /// The repository name. + /// The tag. + public PartialImageRef( Namespace ns, Repository repository, Tag tag ) + : this( repository, tag, null, ns, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The repository name. + /// The digest. + public PartialImageRef( Repository repository, Digest digest ) + : this( repository, null, null, null, digest ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The repository name. + /// The tag. + /// The digest. + public PartialImageRef( Repository repository, Tag tag, Digest digest ) + : this( repository, tag, null, null, digest ) { + } + + #endregion + +#pragma warning disable SA1124 + + #region Any registry + +#pragma warning restore SA1124 + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The repository name. + public PartialImageRef( Registry registry, Repository repository ) + : this( repository, null, registry, null, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The repository name. + /// The tag. + public PartialImageRef( Registry registry, Repository repository, Tag tag ) + : this( repository, tag, registry, null, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The repository name. + /// The digest. + public PartialImageRef( Registry registry, Repository? repository, Digest digest ) + : this( repository, null, registry, null, digest ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The namespace. + /// The repository name. + public PartialImageRef( Registry registry, Namespace ns, Repository repository ) + : this( repository, null, registry, ns, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The namespace. + /// The repository name. + /// The tag. + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Tag tag ) + : this( repository, tag, registry, ns, null ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The namespace. + /// The repository name. + /// The digest. + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Digest digest ) + : this( repository, null, registry, ns, digest ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The repository name. + /// The tag. + /// The digest. + public PartialImageRef( Registry registry, Repository repository, Tag tag, Digest digest ) + : this( repository, tag, registry, null, digest ) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The registry. + /// The namespace. + /// The repository name. + /// The tag. + /// The digest. + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Tag tag, Digest digest ) + : this( repository, tag, registry, ns, digest ) { + } + + #endregion + + /// + /// Returns a new instance with the specified tag. + /// + /// The tag. + /// A new with the specified tag. + public PartialImageRef With( Tag? tag ) => + new(Repository, tag, Registry, Namespace, Digest); + + /// + /// Returns a new instance with the specified registry. + /// + /// The registry. + /// A new with the specified registry. + public PartialImageRef With( Registry? registry ) => + new(Repository, Tag, registry, Namespace, Digest); + + /// + /// Returns a new instance with the specified registry and namespace. + /// + /// The registry. + /// The namespace. + /// A new with the specified registry and namespace. + public PartialImageRef With( Registry registry, Namespace ns ) => + new(Repository, Tag, registry, ns, Digest); + + /// + /// Returns a new instance with the specified namespace. + /// + /// The namespace. + /// A new with the specified namespace. + public PartialImageRef With( Namespace? ns ) => + new(Repository, Tag, Registry, ns, Digest); + + /// + /// Returns a new instance with the specified digest. + /// + /// The digest. + /// A new with the specified digest. + public PartialImageRef With( Digest? digest ) => + new(Repository, Tag, Registry, Namespace, digest); + + /// + /// Tries to convert this partial reference to a qualified reference by applying default conventions. + /// + /// When this method returns, contains the qualified reference if qualification succeeded, or null if it failed. + /// When this method returns, contains an error message if qualification failed, or null if it succeeded. + /// true if qualification succeeded; otherwise, false. + public bool TryQualify( out QualifiedImageRef? qualified, out string? reason ) { + try { + qualified = Qualify(); + reason = null; + return true; + } +#pragma warning disable CA1031 + catch ( Exception ex ) { +#pragma warning restore CA1031 + qualified = null; + reason = ex.Message; + return false; + } + } + + /// + /// Converts this reference to a qualified form by applying default conventions. + /// Fills in defaults for missing registry/namespace based on common conventions (e.g., Docker Hub defaults). + /// Either a tag or digest must be present. + /// + /// The qualification mode to use. + /// A qualified image reference. + /// Thrown if repository is missing or if neither tag nor digest is present. + public QualifiedImageRef Qualify( QualificationMode mode = QualificationMode.DefaultFilling ) { + // Defaults + var registry = Registry ?? Registry.DockerHub; + var ns = Namespace ?? ( registry == Registry.DockerHub ? Namespace.Library : null ); + var repo = Repository ?? throw new InvalidOperationException( "Repository is required" ); + var tag = Tag ?? ( Digest is null ? Tag.Latest : null ); + + if ( tag is null && Digest is null ) { + throw new InvalidOperationException( "Canonical image reference must have either a tag or a digest." ); + } + + return new QualifiedImageRef( registry, ns, repo, tag, Digest ); + } + + /// + /// Converts this reference to a canonical (digest-pinned) form using the current digest. + /// + /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// Specifies how to handle missing components when qualifying. + /// A canonical image reference. + /// Thrown when digest is not present. + public CanonicalImageRef Canonicalize( + CanonicalizationMode canonicalizationMode = CanonicalizationMode.ExcludeTag, + QualificationMode qualificationMode = QualificationMode.DefaultFilling + ) { + return Qualify( qualificationMode ).Canonicalize( canonicalizationMode ); + } + + /// + /// Converts this reference to a canonical (digest-pinned) form with a specific digest. + /// + /// The digest to use. + /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// Specifies how to handle missing components when qualifying. + /// A canonical image reference. + public CanonicalImageRef Canonicalize( + Digest digest, + CanonicalizationMode canonicalizationMode = CanonicalizationMode.ExcludeTag, + QualificationMode qualificationMode = QualificationMode.DefaultFilling + ) { + return With( digest ).Canonicalize( canonicalizationMode, qualificationMode ); + } + + /// + /// Returns the string representation of this partial image reference. + /// + /// A string representation of this partial image reference. + public override string ToString() { + var reg = Registry is null ? string.Empty : $"{Registry}/"; + var ns = Namespace is null ? string.Empty : $"{Namespace}/"; + var repo = Repository is null ? string.Empty : $"{Repository}"; + var tag = Tag is null ? string.Empty : $":{Tag}"; + var digest = Digest is null ? string.Empty : $"@{Digest}"; + return $"{reg}{ns}{repo}{tag}{digest}"; + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/QualificationMode.cs b/dotnet/ImageReferences/QualificationMode.cs new file mode 100644 index 0000000..5c161da --- /dev/null +++ b/dotnet/ImageReferences/QualificationMode.cs @@ -0,0 +1,16 @@ +namespace HLabs.ImageReferences; + +/// +/// Specifies how to handle missing components when qualifying a partial image reference. +/// +public enum QualificationMode { + /// + /// Requires all components to be explicitly provided. + /// + RequireAll, + + /// + /// Fills in missing components with defaults (e.g., docker.io for registry, library for namespace on DockerHub). + /// + DefaultFilling +} \ No newline at end of file diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs new file mode 100644 index 0000000..659bb3f --- /dev/null +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -0,0 +1,118 @@ +namespace HLabs.ImageReferences; + +/// +/// Fully qualified image reference that can address a specific image. +/// The underlying image may change over time (e.g., tag can be moved). +/// Requires registry and repository, with namespace depending on registry requirements. +/// Must have either a tag or digest. +/// +public sealed record QualifiedImageRef : ImageRef { + /// + /// Gets the registry. + /// + public new Registry Registry { + get; + } + + /// + /// Gets the repository. + /// + public new Repository Repository { + get; + } + + internal QualifiedImageRef( Registry registry, Namespace? ns, Repository repository, Tag? tag, Digest? digest ) { + Registry = registry ?? throw new ArgumentNullException( nameof(registry) ); + Namespace = ns ?? ( Registry.NamespaceRequired ? throw new ArgumentNullException( nameof(ns) ) : null ); + Repository = repository ?? throw new ArgumentNullException( nameof(repository) ); + + if ( tag is null && digest is null ) { + throw new InvalidOperationException( "Canonical image reference must have either a tag or a digest." ); + } + + Tag = tag; + Digest = digest; + } + + /// + /// Returns a new instance with the specified tag. + /// + /// The tag. + /// A new with the specified tag. + public QualifiedImageRef With( Tag? tag ) => + new(Registry, Namespace, Repository, tag, Digest); + + /// + /// Returns a new instance with the specified registry. + /// + /// The registry. + /// A new with the specified registry. + public QualifiedImageRef With( Registry registry ) => + new(registry, Namespace, Repository, Tag, Digest); + + /// + /// Returns a new instance with the specified registry and namespace. + /// + /// The registry. + /// The namespace. + /// A new with the specified registry and namespace. + public QualifiedImageRef With( Registry registry, Namespace ns ) => + new(registry, ns, Repository, Tag, Digest); + + /// + /// Returns a new instance with the specified namespace. + /// + /// The namespace. + /// A new with the specified namespace. + public QualifiedImageRef With( Namespace? ns ) => + new(Registry, ns, Repository, Tag, Digest); + + /// + /// Returns a new instance with the specified digest. + /// + /// The digest. + /// A new with the specified digest. + public QualifiedImageRef With( Digest? digest ) => + new(Registry, Namespace, Repository, Tag, digest); + + /// + /// Converts this reference to a canonical (digest-pinned) form with a specific digest. + /// + /// The digest to pin the reference with. + /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// A canonical image reference. + /// Thrown when digest is null. + public CanonicalImageRef Canonicalize( Digest digest, CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { + ArgumentNullException.ThrowIfNull( digest ); + + var tag = mode == CanonicalizationMode.MaintainTag ? Tag : null; + return new CanonicalImageRef( Registry, Namespace, Repository, digest, tag ); + } + + /// + /// Converts this reference to a canonical (digest-pinned) form using the current digest. + /// + /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// A canonical image reference. + /// Thrown when digest is not present. + public CanonicalImageRef Canonicalize( CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { + if ( Digest is null ) { + throw new InvalidOperationException( + "Cannot canonicalize without a digest. Use Canonicalize(digest) to provide one." ); + } + + var tag = mode == CanonicalizationMode.MaintainTag ? Tag : null; + return new CanonicalImageRef( Registry, Namespace, Repository, Digest, tag ); + } + + /// + /// Returns the string representation of this qualified image reference. + /// + /// A string representation of this qualified image reference. + public override string ToString() { + var nsPart = Namespace is not null ? $"{Namespace}/" : string.Empty; + var tagPart = Tag is not null ? $":{Tag}" : string.Empty; + var digestPart = Digest is not null ? $"@{Digest}" : string.Empty; + return $"{Registry}/{nsPart}{Repository}{tagPart}{digestPart}"; + } +} \ No newline at end of file diff --git a/dotnet/ImageReferences/README.md b/dotnet/ImageReferences/README.md new file mode 100644 index 0000000..fb4e8ff --- /dev/null +++ b/dotnet/ImageReferences/README.md @@ -0,0 +1,77 @@ +# `HLabs.ImageReferences` 🐋 + +Strongly-typed container image references for .NET. + +```bash +dotnet add package HLabs.ImageReferences +``` + +## Getting Started + +```csharp +var partial = "nginx".Image(); // nginx + +var qualified = partial.Qualified(); // docker.io/library/nginx:latest + +var canonical = partial.Canonical("57e903..."); // docker.io/library/nginx@sha256:57e903... +``` + +### Parsing references + +```csharp +var image = "ghcr.io/myorg/myapp:3.1.0".Image(); + +Registry reg = image.Registry; // ghcr.io +Namespace ns = image.Namespace; // myorg +Repository repo = image.Repository; // myapp +Tag tag = image.Tag; // 3.1.0 +``` + +### Modifying references + +```csharp +var dev = new ImageReference( "myapp", Tag.Latest, Registry.Localhost ); // localhost:5000/myapp:latest +var prod = dev.With( Registry.DockerHub, "myorg" ); // docker.io/myorg/myapp:latest +var pinned = prod.With( new SemVersion(2, 1, 0)); // docker.io/myorg/myapp:2.1.0 +var withDigest = pinned.With( "sha256:a3ed95caeb02..." ); // docker.io/myorg/myapp@sha256:a3ed95caeb02... +``` + +### Built-in registries and tags + +```csharp +Registry.DockerHub // docker.io +Registry.GitHub // ghcr.io +Registry.Quay // quay.io +Registry.Localhost // localhost:5000 +Registry.Acr("mycompany") // mycompany.azurecr.io +Registry.Ecr("1234", "eu-west-1") // 1234.dkr.ecr.eu-west-1.amazonaws.com + +Tag.Latest // latest +``` + +### Extending with custom tags + +Define your own well-known tags (or registries, repositories, etc.) using C# 14 extensions: + +```csharp +static class MyExtensions +{ + extension(Tag) + { + static Tag Dev => new("dev"); + static Tag Alpha(uint n) => new($"alpha-{n}"); + } + + extension(Registry) + { + static Registry Internal => Registry.Ecr("1234", "eu-west-1") + } +} +``` + +Then use them naturally: + +```csharp +var image = "myapp".Image( Tag.Dev, Registry.Localhost); // localhost:5000/myapp:dev +var alpha = image.With( Registry.Internal, Tag.Alpha(3) ); // 1234.dkr.ecr.eu-west-1.amazonaws.com/myapp:alpha-3 +```