From 5c98e02fa28dc5c7642fc57e7edd8bca6225ff12 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 8 Feb 2026 01:17:11 +0100 Subject: [PATCH 01/36] major refactor --- .editorconfig | 7 + Directory.Build.props | 5 + Directory.Packages.props | 5 +- HLabs.slnx | 8 +- dotnet/Containers.Tests/DigestTests.cs | 60 +++++ .../Containers.Tests/ImageReferenceTests.cs | 235 ++++++++++++++++-- dotnet/Containers.Tests/NamespaceTests.cs | 41 +++ dotnet/Containers.Tests/RegistryTests.cs | 64 +++++ dotnet/Containers.Tests/RepositoryTests.cs | 53 ++++ dotnet/Containers.Tests/TagTests.cs | 74 ++++++ dotnet/Containers/ContainerRegistry.cs | 30 --- dotnet/Containers/Digest.cs | 31 +++ dotnet/Containers/ImageReference.Parsing.cs | 63 +++++ dotnet/Containers/ImageReference.cs | 100 +++----- dotnet/Containers/Namespace.Instances.cs | 8 + dotnet/Containers/Namespace.cs | 38 +++ dotnet/Containers/README.md | 85 ++++++- dotnet/Containers/Registry.Instances.cs | 28 +++ dotnet/Containers/Registry.cs | 27 ++ dotnet/Containers/Repository.cs | 31 +++ dotnet/Containers/Tag.Instances.cs | 5 + dotnet/Containers/Tag.cs | 48 ++-- 22 files changed, 894 insertions(+), 152 deletions(-) create mode 100644 dotnet/Containers.Tests/DigestTests.cs create mode 100644 dotnet/Containers.Tests/NamespaceTests.cs create mode 100644 dotnet/Containers.Tests/RegistryTests.cs create mode 100644 dotnet/Containers.Tests/RepositoryTests.cs create mode 100644 dotnet/Containers.Tests/TagTests.cs delete mode 100644 dotnet/Containers/ContainerRegistry.cs create mode 100644 dotnet/Containers/Digest.cs create mode 100644 dotnet/Containers/ImageReference.Parsing.cs create mode 100644 dotnet/Containers/Namespace.Instances.cs create mode 100644 dotnet/Containers/Namespace.cs create mode 100644 dotnet/Containers/Registry.Instances.cs create mode 100644 dotnet/Containers/Registry.cs create mode 100644 dotnet/Containers/Repository.cs create mode 100644 dotnet/Containers/Tag.Instances.cs diff --git a/.editorconfig b/.editorconfig index a121307..4edcea4 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,9 @@ 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.NX0002.severity = suggestion diff --git a/Directory.Build.props b/Directory.Build.props index a6ff2cb..334a948 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,10 +16,15 @@ false All true + + $(NoWarn);SA0001 + \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index b805747..2907af5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,9 +13,10 @@ + - + - + \ No newline at end of file diff --git a/HLabs.slnx b/HLabs.slnx index 8401764..921a6cd 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -4,11 +4,17 @@ + + + + + + + - diff --git a/dotnet/Containers.Tests/DigestTests.cs b/dotnet/Containers.Tests/DigestTests.cs new file mode 100644 index 0000000..4782221 --- /dev/null +++ b/dotnet/Containers.Tests/DigestTests.cs @@ -0,0 +1,60 @@ +namespace HLabs.Containers.Tests; + +internal sealed class DigestTests { + [Test] + public async Task ConstructorSetsValue() { + var digest = new Digest( "sha256:abc123" ); + await Assert.That( digest.ToString() ).IsEqualTo( "sha256:abc123" ); + } + + [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 = "sha256:abc"; + await Assert.That( d.ToString() ).IsEqualTo( "sha256:abc" ); + } + + [Test] + public async Task ExplicitConversionFromString() { + var d = Digest.FromString( "sha256:abc" ); + await Assert.That( d.ToString() ).IsEqualTo( "sha256:abc" ); + } + + [Test] + public async Task EqualDigestsAreEqual() { + var a = new Digest( "sha256:abc" ); + var b = new Digest( "sha256:abc" ); + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentDigestsAreNotEqual() { + var a = new Digest( "sha256:abc" ); + var b = new Digest( "sha256:def" ); + await Assert.That( a ).IsNotEqualTo( b ); + } +} \ No newline at end of file diff --git a/dotnet/Containers.Tests/ImageReferenceTests.cs b/dotnet/Containers.Tests/ImageReferenceTests.cs index cd22b56..fc77a7b 100644 --- a/dotnet/Containers.Tests/ImageReferenceTests.cs +++ b/dotnet/Containers.Tests/ImageReferenceTests.cs @@ -3,50 +3,239 @@ namespace HLabs.Containers.Tests; internal sealed class ImageReferenceTests { - public static IEnumerable<(ImageReference, string)> SuccessTestCases { + // ----------------------- + // ToString round-trip + // ----------------------- + public static IEnumerable<(ImageReference, string)> ToStringCases { get { + // DockerHub defaults yield return ( - ImageReference.Localhost( "drift", Tag.Latest ), - "localhost:5000/drift:latest" + new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub, new Namespace( "library" ) ), + "docker.io/library/ubuntu:latest" + ); + yield return ( + new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub ), + "docker.io/library/ubuntu:latest" + ); + yield return ( + new ImageReference( new Repository( "ubuntu" ), Tag.Latest ), + "docker.io/library/ubuntu:latest" + ); + yield return ( + new ImageReference( new Repository( "ubuntu" ) ), + "docker.io/library/ubuntu:latest" + ); + yield return ( + new ImageReference( "ubuntu" ), + "docker.io/library/ubuntu:latest" + ); + // Implicit conversions + yield return ( + new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub, "library" ), + "docker.io/library/ubuntu:latest" ); + // Custom namespace on DockerHub yield return ( - ImageReference.DockerIo( "hojmark", "drift", Tag.Version( new SemVersion( 1, 21, 1 ) ) ), + new ImageReference( "drift", Tag.Latest, @namespace: "hojmark" ), + "docker.io/hojmark/drift:latest" + ); + yield return ( + new ImageReference( "drift", new SemVersion( 1, 21, 1 ), @namespace: "hojmark" ), + "docker.io/hojmark/drift:1.21.1" + ); + yield return ( + new ImageReference( "drift", new SemVersion( 1, 21, 1 ), Registry.DockerHub, "hojmark" ), "docker.io/hojmark/drift:1.21.1" ); + // Localhost (no namespace) + yield return ( + new ImageReference( new Repository( "drift" ), Tag.Latest, Registry.Localhost ), + "localhost:5000/drift:latest" + ); + yield return ( + new ImageReference( new Repository( "drift" ), Tag.Dev, Registry.Localhost ), + "localhost:5000/drift:dev" + ); yield return ( - ImageReference.Localhost( "drift", Tag.Version( new SemVersion( 2, 0, 0 ) ) ), + new ImageReference( new Repository( "drift" ), new SemVersion( 2, 0, 0 ), Registry.Localhost ), "localhost:5000/drift:2.0.0" ); + // Other well-known registries yield return ( - ImageReference.Localhost( "drift", Tag.Dev ), "localhost:5000/drift:dev" + new ImageReference( "myapp", Tag.Latest, Registry.GitHub, "myorg" ), + "ghcr.io/myorg/myapp:latest" + ); + yield return ( + new ImageReference( "myapp", Tag.Latest, Registry.Quay, "myorg" ), + "quay.io/myorg/myapp:latest" + ); + // With digest + yield return ( + new ImageReference( "nginx", Tag.Latest, Registry.DockerHub, "library", new Digest( "sha256:abc123" ) ), + "docker.io/library/nginx:latest@sha256:abc123" ); } } - public static IEnumerable> FailureTestCases { + [Test] + [MethodDataSource( nameof(ToStringCases) )] + public async Task ToStringTest( ImageReference reference, string expected ) { + await Assert.That( reference.ToString() ).IsEqualTo( expected ); + } + + // ----------------------- + // Parse round-trip + // ----------------------- + public static IEnumerable<(string, string)> ParseCases { 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 ) ); + yield return ( "ubuntu", "docker.io/library/ubuntu:latest" ); + yield return ( "docker.io/library/ubuntu", "docker.io/library/ubuntu:latest" ); + 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" ); } } [Test] - [MethodDataSource( nameof(SuccessTestCases) )] - public async Task SerializationSuccessTest( ImageReference reference, string expected ) { - await Assert.That( reference.ToString() ).IsEqualTo( expected ); + [MethodDataSource( nameof(ParseCases) )] + public async Task ParseRoundTripTest( string input, string expected ) { + var parsed = ImageReference.Parse( input ); + await Assert.That( parsed.ToString() ).IsEqualTo( expected ); + } + + // ----------------------- + // Parse failures + // ----------------------- + [Test] + public void ParseNullThrows() { + Assert.Throws( () => ImageReference.Parse( null! ) ); + } + + [Test] + public void ParseEmptyThrows() { + // Empty string will either fail regex or fail Repository validation + Assert.Throws( () => ImageReference.Parse( string.Empty ) ); + } + + // ----------------------- + // TryParse + // ----------------------- + [Test] + public async Task TryParseValidInputReturnsTrue() { + var success = ImageReference.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 = ImageReference.TryParse( null, out var result ); + await Assert.That( success ).IsFalse(); + await Assert.That( result ).IsNull(); + } + + // ----------------------- + // Equality (record semantics) + // ----------------------- + [Test] + public async Task EqualReferencesAreEqual() { + var a = new ImageReference( "nginx", Tag.Latest ); + var b = new ImageReference( "nginx", Tag.Latest ); + await Assert.That( a ).IsEqualTo( b ); + } + + [Test] + public async Task DifferentTagsAreNotEqual() { + var a = new ImageReference( "nginx", Tag.Latest ); + var b = new ImageReference( "nginx", Tag.Dev ); + await Assert.That( a ).IsNotEqualTo( b ); + } + + // ----------------------- + // Construction validation + // ----------------------- + [Test] + public void EmptyRepositoryThrows() { + Assert.Throws( () => new ImageReference( new Repository( string.Empty ) ) ); + } + + [Test] + public void WhitespaceRepositoryThrows() { + Assert.Throws( () => new ImageReference( new Repository( " " ) ) ); + } + + [Test] + public void RepositoryWithSlashThrows() { + Assert.Throws( () => new ImageReference( new Repository( "a/b" ) ) ); + } + + // ----------------------- + // Record `with` expressions + // ----------------------- + [Test] + public async Task WithTagReplacesTag() { + var original = new ImageReference( "nginx", new SemVersion( 1, 25, 0 ) ); + var updated = original with { Tag = Tag.Latest }; + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + } + + [Test] + public async Task WithTagPreservesOtherProperties() { + var original = new ImageReference( "drift", new SemVersion( 1, 0, 0 ), Registry.Localhost ); + var updated = original with { Tag = 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 ImageReference( "myapp", Tag.Latest, @namespace: "team" ); + var updated = original with { Registry = Registry.GitHub }; + await Assert.That( updated.ToString() ).IsEqualTo( "ghcr.io/team/myapp:latest" ); + } + + [Test] + public async Task WithNamespaceReplacesNamespace() { + var original = new ImageReference( "drift", Tag.Latest ); + var updated = original with { Namespace = new Namespace( "hojmark" ) }; + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/hojmark/drift:latest" ); + } + + [Test] + public async Task WithNamespaceNullRemovesNamespace() { + var original = new ImageReference( "drift", Tag.Latest, @namespace: "hojmark" ); + var updated = original with { Namespace = null }; + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/drift:latest" ); + } + + [Test] + public async Task WithDigestAddsDigest() { + var original = new ImageReference( "nginx", Tag.Latest ); + var updated = original with { Digest = new Digest( "sha256:abc123" ) }; + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest@sha256:abc123" ); + } + + [Test] + public async Task WithDigestNullRemovesDigest() { + var original = new ImageReference( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + var updated = original with { Digest = null }; + await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + } + + [Test] + public async Task WithMultipleProperties() { + var original = new ImageReference( "nginx", Tag.Latest ); + var updated = original with { Tag = new SemVersion( 2, 0, 0 ), Registry = Registry.Localhost, Namespace = null }; + await Assert.That( updated.ToString() ).IsEqualTo( "localhost:5000/nginx:2.0.0" ); } [Test] - [MethodDataSource( nameof(FailureTestCases) )] - public void SerializationFailureTest( Lazy reference ) { - Assert.Throws( () => _ = reference.Value ); + public async Task WithDoesNotMutateOriginal() { + var original = new ImageReference( "nginx", Tag.Latest ); + _ = original with { Tag = new Tag( "alpine" ) }; + await Assert.That( original.Tag ).IsEqualTo( Tag.Latest ); } } \ No newline at end of file diff --git a/dotnet/Containers.Tests/NamespaceTests.cs b/dotnet/Containers.Tests/NamespaceTests.cs new file mode 100644 index 0000000..7eab864 --- /dev/null +++ b/dotnet/Containers.Tests/NamespaceTests.cs @@ -0,0 +1,41 @@ +namespace HLabs.Containers.Tests; + +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/Containers.Tests/RegistryTests.cs b/dotnet/Containers.Tests/RegistryTests.cs new file mode 100644 index 0000000..1544083 --- /dev/null +++ b/dotnet/Containers.Tests/RegistryTests.cs @@ -0,0 +1,64 @@ +namespace HLabs.Containers.Tests; + +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" ); + await Assert.That( a ).IsEqualTo( Registry.DockerHub ); + } +} \ No newline at end of file diff --git a/dotnet/Containers.Tests/RepositoryTests.cs b/dotnet/Containers.Tests/RepositoryTests.cs new file mode 100644 index 0000000..ad86068 --- /dev/null +++ b/dotnet/Containers.Tests/RepositoryTests.cs @@ -0,0 +1,53 @@ +namespace HLabs.Containers.Tests; + +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/Containers.Tests/TagTests.cs b/dotnet/Containers.Tests/TagTests.cs new file mode 100644 index 0000000..9c33717 --- /dev/null +++ b/dotnet/Containers.Tests/TagTests.cs @@ -0,0 +1,74 @@ +using Semver; + +namespace HLabs.Containers.Tests; + +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/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/Digest.cs b/dotnet/Containers/Digest.cs new file mode 100644 index 0000000..53bf7b7 --- /dev/null +++ b/dotnet/Containers/Digest.cs @@ -0,0 +1,31 @@ +namespace HLabs.Containers; + +public sealed record Digest { + private string Value { + get; + } + + 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) ); + } + + if ( !value.Contains( ':', StringComparison.Ordinal ) ) { + throw new ArgumentException( "Digest must be in 'algorithm:hex' format (e.g. 'sha256:abc123')", nameof(value) ); + } + + Value = value; + } + + public static implicit operator Digest( string value ) => FromString( value ); + + public static Digest FromString( string value ) { + return new(value); + } + + public override string ToString() => Value; +} \ No newline at end of file diff --git a/dotnet/Containers/ImageReference.Parsing.cs b/dotnet/Containers/ImageReference.Parsing.cs new file mode 100644 index 0000000..04920a1 --- /dev/null +++ b/dotnet/Containers/ImageReference.Parsing.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; + +namespace HLabs.Containers; + +public sealed partial record ImageReference { + [GeneratedRegex( + @"^(?:(?[^/]+?)/)?(?:(?[^/]+?)/)?(?[^:@]+)(?::(?[^@]+))?(?:@(?.+))?$", + RegexOptions.CultureInvariant + )] + private static partial Regex ImageRefRegex(); + + public static ImageReference Parse( string imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + + var match = ImageRefRegex().Match( imageReference.Trim() ); + if ( !match.Success ) { + throw new FormatException( $"Invalid image reference: '{imageReference}'" ); + } + + var registryStr = match.Groups["registry"].Value; + var namespaceStr = match.Groups["namespace"].Value; + var repositoryStr = match.Groups["repository"].Value; + var tagStr = match.Groups["tag"].Value; + var digestStr = match.Groups["digest"].Value; + + var registry = string.IsNullOrEmpty( registryStr ) + ? Registry.DockerHub + : new Registry( registryStr ); + + var @namespace = string.IsNullOrEmpty( namespaceStr ) + ? ( registry == Registry.DockerHub ? Namespace.Library : null ) + : new Namespace( namespaceStr ); + + var repository = new Repository( repositoryStr ); + + var tag = string.IsNullOrEmpty( tagStr ) ? Tag.Latest : new Tag( tagStr ); + + var digest = string.IsNullOrEmpty( digestStr ) + ? null + : new Digest( digestStr ); + + return new ImageReference( repository, tag, registry, @namespace, digest ); + } + + public static bool TryParse( string? input, out ImageReference? 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/Containers/ImageReference.cs b/dotnet/Containers/ImageReference.cs index b683f69..d4d7dc1 100644 --- a/dotnet/Containers/ImageReference.cs +++ b/dotnet/Containers/ImageReference.cs @@ -1,88 +1,60 @@ -using Semver; - namespace HLabs.Containers; -public record ImageReference { - public required ContainerRegistry Host { +// TODO support platform +// TODO docs +/// +/// A fully-qualified container image reference. +/// +/// example.com:5000/team/my-app:2.0 +/// +/// Host: example.com:5000 +/// Namespace: team +/// Repository: my-app +/// Tag: 2.0 +/// +/// +public sealed partial record ImageReference { + public Registry Registry { get; init; } - public string? Namespace { + public Namespace? Namespace { get; init; } - public required string Repository { + public Repository Repository { get; - init; } - public required Tag Tag { + public 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 Digest? Digest { + get; + init; } - 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 ImageReference( + Repository repository, + Tag? tag = null, + Registry? host = null, + Namespace? @namespace = null, + Digest? digest = null + ) { + Repository = repository; + Tag = tag ?? Tag.Latest; + Registry = host ?? Registry.DockerHub; + Namespace = @namespace ?? ( Registry == Registry.DockerHub ? Namespace.Library : null ); + Digest = digest; } 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 }; + var ns = Namespace is null ? string.Empty : $"{Namespace}/"; + var digest = Digest is null ? string.Empty : $"@{Digest}"; + return $"{Registry}/{ns}{Repository}:{Tag}{digest}"; } } \ No newline at end of file diff --git a/dotnet/Containers/Namespace.Instances.cs b/dotnet/Containers/Namespace.Instances.cs new file mode 100644 index 0000000..27a2115 --- /dev/null +++ b/dotnet/Containers/Namespace.Instances.cs @@ -0,0 +1,8 @@ +namespace HLabs.Containers; + +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/Containers/Namespace.cs b/dotnet/Containers/Namespace.cs new file mode 100644 index 0000000..daaf3b8 --- /dev/null +++ b/dotnet/Containers/Namespace.cs @@ -0,0 +1,38 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HLabs.Containers; + +[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; + } + + 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) ); + } + + Name = name; + } + + public static implicit operator Namespace( string value ) => FromString( value ); + + public override string ToString() => Name; + + public static Namespace FromString( string value ) { + return new(value); + } +} \ No newline at end of file diff --git a/dotnet/Containers/README.md b/dotnet/Containers/README.md index 489cd5d..4e6c4c9 100644 --- a/dotnet/Containers/README.md +++ b/dotnet/Containers/README.md @@ -1,9 +1,9 @@ # HLabs.Containers -A .NET library for describing, validating, and manipulating container image references. +Strongly-typed, validated container image references for .NET. -Provides strongly typed representations of container registries, image references, and tags, making it easy to work with -container images .NET projects. +Build, parse, and manipulate Docker/OCI image references (like `docker.io/library/nginx:1.25`) with types that enforce +correctness at construction time — no more stringly-typed image names scattered across your codebase. --- @@ -15,7 +15,80 @@ Install via NuGet: dotnet add package HLabs.Containers ``` -## Usage -```bash -TODO +## Getting Started + +```csharp +var image = new ImageReference("nginx"); +Console.WriteLine(image); // docker.io/library/nginx:latest +``` + +### Building references + +```csharp +// Implicit construction +var image = new ImageReference("nginx", "trixie"); // → docker.io/library/nginx:trixie + +// Explicit construction +var image = new ImageReference(new Repository("nginx"), new Tag("trixie")); // → docker.io/library/nginx:trixie + +// Semantic Versioning support (via the Semver package) +var image = new ImageReference("myapp", new SemVersion(3, 1, 0), Registry.GitHub, "myorg"); // → ghcr.io/myorg/myapp:3.1.0 +``` + +### Parsing references + +```csharp +var image = ImageReference.Parse("ghcr.io/myorg/myapp:3.1.0"); +Console.WriteLine(image.Registry); // ghcr.io +Console.WriteLine(image.Namespace); // myorg +Console.WriteLine(image.Repository); // myapp +Console.WriteLine(image.Tag); // 3.1.0 +``` + +### Modifying using `with` expressions + +`ImageReference` is a C# `record`, so you can create modified copies using `with`: + +```csharp +var dev = new ImageReference("myapp", Tag.Latest, Registry.Localhost); // → localhost:5000/myapp:latest +var prod = dev with { Registry = Registry.DockerHub, Namespace = "myorg" }; // → docker.io/myorg/myapp:latest +var pinned = prod with { Tag = new SemVersion(2, 1, 0) }; // → docker.io/myorg/myapp:2.1.0 +var withDigest = pinned with { Digest = "sha256:a3ed95caeb02..." }; // → docker.io/myorg/myapp:2.1.0@sha256:a3ed95caeb02... +``` + +### Built-in registries and tags + +Common registries and tags are provided as static members: + +```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("123456789", "eu-west-1") // 123456789.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 +internal static class MyTagExtensions +{ + extension(Tag) + { + public static Tag Dev => new("dev"); + public static Tag Alpha(uint n) => new($"alpha-{n}"); + } +} +``` + +Then use them naturally: + +```csharp +var image = new ImageReference("myapp", Tag.Dev, Registry.Localhost); // → localhost:5000/myapp:dev +var alpha = image with { Tag = Tag.Alpha(3) }; // → localhost:5000/myapp:alpha-3 ``` \ No newline at end of file diff --git a/dotnet/Containers/Registry.Instances.cs b/dotnet/Containers/Registry.Instances.cs new file mode 100644 index 0000000..8235c0a --- /dev/null +++ b/dotnet/Containers/Registry.Instances.cs @@ -0,0 +1,28 @@ +namespace HLabs.Containers; + +public sealed partial record Registry { + public static readonly Registry DockerHub = new("docker.io"); + public static readonly Registry Quay = new("quay.io"); + public static readonly Registry GitHub = new("ghcr.io"); + public static readonly Registry Localhost = new("localhost:5000"); + + /// + /// Azure Container Registry. + /// + /// Registry name. + /// The custom ACR registry. + public static Registry Acr( string name ) => new($"{name}.azurecr.io"); + + /// + /// Amazon ECR private registry. + /// + /// Amazon AWS account ID. + /// The region. + /// The custom ECR registry. + 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/Containers/Registry.cs b/dotnet/Containers/Registry.cs new file mode 100644 index 0000000..f54bf06 --- /dev/null +++ b/dotnet/Containers/Registry.cs @@ -0,0 +1,27 @@ +namespace HLabs.Containers; + +public sealed partial record Registry { + private string Host { + get; + } + + public Registry( string host ) { + 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) ); + } + + Host = host; + } + + public static implicit operator Registry( string host ) => FromString( host ); + + public static Registry FromString( string host ) { + return new(host); + } + + public override string ToString() => Host; +} \ No newline at end of file diff --git a/dotnet/Containers/Repository.cs b/dotnet/Containers/Repository.cs new file mode 100644 index 0000000..00582f0 --- /dev/null +++ b/dotnet/Containers/Repository.cs @@ -0,0 +1,31 @@ +namespace HLabs.Containers; + +public sealed record Repository { + private string Name { + get; + } + + 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) ); + } + + Name = name; + } + + public static implicit operator Repository( string value ) => FromString( value ); + + public static Repository FromString( string value ) { + return new(value); + } + + public override string ToString() => Name; +} \ No newline at end of file diff --git a/dotnet/Containers/Tag.Instances.cs b/dotnet/Containers/Tag.Instances.cs new file mode 100644 index 0000000..4561362 --- /dev/null +++ b/dotnet/Containers/Tag.Instances.cs @@ -0,0 +1,5 @@ +namespace HLabs.Containers; + +public sealed partial record Tag { + public static readonly Tag Latest = new("latest"); +} \ No newline at end of file diff --git a/dotnet/Containers/Tag.cs b/dotnet/Containers/Tag.cs index 3daad25..f3fb692 100644 --- a/dotnet/Containers/Tag.cs +++ b/dotnet/Containers/Tag.cs @@ -2,39 +2,35 @@ namespace HLabs.Containers; -public record Tag { - internal Tag( string value ) { - Value = value; - } - +public sealed partial record Tag { private string Value { get; } - // Sealed to prevent children from generating their own auto-ToString implementation - public sealed override string ToString() { - return Value; + 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) ); + } + + Value = value; } -} -public record LatestTag : Tag { - public static readonly LatestTag Instance = new(); + public static implicit operator Tag( string tag ) => FromString( tag ); + + public static implicit operator Tag( SemVersion v ) => FromSemVersion( v ); - private LatestTag() : base( "latest" ) { + public static Tag FromString( string tag ) { + return new(tag); } -} - -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() ); - } + + public static Tag FromSemVersion( SemVersion v ) { + // ! Null check is performed by constructor + return new(v?.WithoutMetadata().ToString()!); } + + public override string ToString() => Value; } \ No newline at end of file From f6d5404c8a7ee39083e4eb041d1401147b980bce Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:32:30 +0100 Subject: [PATCH 02/36] add readme --- README.md | 5 +++++ dotnet/Containers/README.md | 3 +-- 2 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..c8da8b3 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +## NuGet packages + +| Name | Version | +|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| [HLabs.Containers](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.svg)](https://www.nuget.org/packages/HLabs.Containers/) | \ No newline at end of file diff --git a/dotnet/Containers/README.md b/dotnet/Containers/README.md index 4e6c4c9..9414018 100644 --- a/dotnet/Containers/README.md +++ b/dotnet/Containers/README.md @@ -2,8 +2,7 @@ Strongly-typed, validated container image references for .NET. -Build, parse, and manipulate Docker/OCI image references (like `docker.io/library/nginx:1.25`) with types that enforce -correctness at construction time — no more stringly-typed image names scattered across your codebase. +Build, parse, and manipulate Docker/OCI image references (like `docker.io/library/nginx:1.25`). --- From 84938848f6cd4368c3cbc954fe7ff0d3a30eaa05 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:09:51 +0100 Subject: [PATCH 03/36] more rework --- .github/copilot-instructions.md | 1 + AGENTS.md | 12 + Directory.Build.props | 2 +- HLabs.Build.slnx | 12 + HLabs.slnx | 1 + README.md | 7 +- build/NukeBuild.Pack.cs | 71 ++++- .../Containers.Extensions.Nuke.csproj | 21 ++ .../DockerLoginSettingsExtensions.cs | 10 + .../DockerLogoutSettingsExtensions.cs | 10 + .../DockerPushSettingsExtensions.cs | 10 + .../DockerTagSettingsExtensions.cs | 30 ++ .../ExtensionsMore.cs | 41 +++ dotnet/Containers.Extensions.Nuke/README.md | 3 + .../Containers.Tests/ImageReferenceTests.cs | 272 ++++++++++++++---- dotnet/Containers/AGENTS.md | 69 +++++ dotnet/Containers/CanonicalImageRef.cs | 75 +++++ dotnet/Containers/Digest.cs | 38 ++- dotnet/Containers/ImageId.cs | 63 ++++ dotnet/Containers/ImageRef.cs | 44 +++ dotnet/Containers/ImageReference.Parsing.cs | 63 ---- dotnet/Containers/ImageReference.cs | 60 ---- dotnet/Containers/PartialImageRef.Parsing.cs | 53 ++++ dotnet/Containers/PartialImageRef.cs | 201 +++++++++++++ .../Containers/PartialImageRefExtensions.cs | 38 +++ dotnet/Containers/QualificationMode.cs | 6 + dotnet/Containers/QualifiedImageRef.cs | 85 ++++++ dotnet/Containers/README.md | 43 ++- dotnet/Containers/Registry.Instances.cs | 8 +- dotnet/Containers/Registry.cs | 7 +- dotnet/Containers/StringExtensions.cs | 24 ++ dotnet/Containers/Tag.cs | 2 +- 32 files changed, 1185 insertions(+), 197 deletions(-) create mode 120000 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 HLabs.Build.slnx create mode 100644 dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj create mode 100644 dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs create mode 100644 dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs create mode 100644 dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs create mode 100644 dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs create mode 100644 dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs create mode 100644 dotnet/Containers.Extensions.Nuke/README.md create mode 100644 dotnet/Containers/AGENTS.md create mode 100644 dotnet/Containers/CanonicalImageRef.cs create mode 100644 dotnet/Containers/ImageId.cs create mode 100644 dotnet/Containers/ImageRef.cs delete mode 100644 dotnet/Containers/ImageReference.Parsing.cs delete mode 100644 dotnet/Containers/ImageReference.cs create mode 100644 dotnet/Containers/PartialImageRef.Parsing.cs create mode 100644 dotnet/Containers/PartialImageRef.cs create mode 100644 dotnet/Containers/PartialImageRefExtensions.cs create mode 100644 dotnet/Containers/QualificationMode.cs create mode 100644 dotnet/Containers/QualifiedImageRef.cs create mode 100644 dotnet/Containers/StringExtensions.cs 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..49a8c48 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# AGENTS.md + +## Project structure + +- `dotnet/Containers/` - Build, parse, and manipulate container image references +- `dotnet/Containers.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 334a948..8904e1b 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 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 921a6cd..eb62065 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -17,4 +17,5 @@ + diff --git a/README.md b/README.md index c8da8b3..5ffb0e1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ ## NuGet packages -| Name | Version | -|-------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| -| [HLabs.Containers](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.svg)](https://www.nuget.org/packages/HLabs.Containers/) | \ No newline at end of file +| Name | Version | +|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| +| [HLabs.Containers](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.svg)](https://www.nuget.org/packages/HLabs.Containers/) | +| [HLabs.Containers.Extensions.Nuke](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.Extensions.Nuke.svg)](https://www.nuget.org/packages/HLabs.Containers.Extensions.Nuke/) | \ No newline at end of file diff --git a/build/NukeBuild.Pack.cs b/build/NukeBuild.Pack.cs index a733827..13dfc9e 100644 --- a/build/NukeBuild.Pack.cs +++ b/build/NukeBuild.Pack.cs @@ -1,4 +1,8 @@ +using System; using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Xml.Linq; using Nuke.Common; using Nuke.Common.IO; using Nuke.Common.Tools.DotNet; @@ -35,6 +39,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 +47,68 @@ internal partial class NukeBuild { } } ); -} \ No newline at end of file + + 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 ); + + // NuSpec uses namespaces — ignore them safely + 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 ); +} +} diff --git a/dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj b/dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj new file mode 100644 index 0000000..768a55c --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj @@ -0,0 +1,21 @@ + + + + Describe, validate, and manipulate container image references + NUKE Docker container containers + true + + + + + + + + + + + + + + + diff --git a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs new file mode 100644 index 0000000..8425ae4 --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -0,0 +1,10 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.Containers.Extensions.Nuke; + +public static class DockerLoginSettingsExtensions { + 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/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs new file mode 100644 index 0000000..9098581 --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -0,0 +1,10 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.Containers.Extensions.Nuke; + +public static class DockerLogoutSettingsExtensions { + 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/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs new file mode 100644 index 0000000..fc84d12 --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs @@ -0,0 +1,10 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.Containers.Extensions.Nuke; + +public static class DockerPushSettingsExtensions { + 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/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs new file mode 100644 index 0000000..83b0c95 --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs @@ -0,0 +1,30 @@ +using Nuke.Common.Tools.Docker; + +namespace HLabs.Containers.Extensions.Nuke; + +public static class DockerTagSettingsExtensions { + /*public static DockerBuildSettings SetTag( this DockerBuildSettings settings, QualifiedImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return DockerBuildSettingsExtensions.SetTag( settings, imageReference.ToString() ); + }*/ + + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, ImageId imageId ) { + ArgumentNullException.ThrowIfNull( imageId ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( settings, imageId.ToString() ); + } + + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, QualifiedImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( settings, imageReference.ToString() ); + } + + public static DockerTagSettings SetSourceImage( this DockerTagSettings settings, CanonicalImageRef imageReference ) { + ArgumentNullException.ThrowIfNull( imageReference ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetSourceImage( settings, imageReference.ToString() ); + } + + 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/Containers.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs new file mode 100644 index 0000000..05ca69c --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs @@ -0,0 +1,41 @@ +using Nuke.Common.Tooling; +using Nuke.Common.Tools.Docker; + +namespace HLabs.Containers.Extensions.Nuke; + +public static class LocalDockerRepositoryExtensions { + 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 ) { +#pragma warning disable CA2201 + throw new Exception( $"Unexcepted line from docker image ls: '{line}'" ); +#pragma warning restore CA2201 + } + + 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/Containers.Extensions.Nuke/README.md b/dotnet/Containers.Extensions.Nuke/README.md new file mode 100644 index 0000000..d315af7 --- /dev/null +++ b/dotnet/Containers.Extensions.Nuke/README.md @@ -0,0 +1,3 @@ +# HLabs.Containers.Extensions.Nuke + +This repository contains NUKE extensions for HLabs.Containers. \ No newline at end of file diff --git a/dotnet/Containers.Tests/ImageReferenceTests.cs b/dotnet/Containers.Tests/ImageReferenceTests.cs index fc77a7b..43d27e6 100644 --- a/dotnet/Containers.Tests/ImageReferenceTests.cs +++ b/dotnet/Containers.Tests/ImageReferenceTests.cs @@ -6,101 +6,142 @@ internal sealed class ImageReferenceTests { // ----------------------- // ToString round-trip // ----------------------- - public static IEnumerable<(ImageReference, string)> ToStringCases { + public static IEnumerable<(PartialImageRef, string)> ToStringCases { get { // DockerHub defaults yield return ( - new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub, new Namespace( "library" ) ), + new PartialImageRef( + Registry.DockerHub, + new Namespace( "library" ), + new Repository( "ubuntu" ), + Tag.Latest + ), "docker.io/library/ubuntu:latest" ); yield return ( - new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub ), + new PartialImageRef( Registry.DockerHub, new Repository( "ubuntu" ), Tag.Latest ), "docker.io/library/ubuntu:latest" ); yield return ( - new ImageReference( new Repository( "ubuntu" ), Tag.Latest ), + new PartialImageRef( new Repository( "ubuntu" ), Tag.Latest ), "docker.io/library/ubuntu:latest" ); + // No tag — no :latest in output yield return ( - new ImageReference( new Repository( "ubuntu" ) ), - "docker.io/library/ubuntu:latest" + new PartialImageRef( new Repository( "ubuntu" ) ), + "docker.io/library/ubuntu" ); yield return ( - new ImageReference( "ubuntu" ), - "docker.io/library/ubuntu:latest" + new PartialImageRef( "ubuntu" ), + "docker.io/library/ubuntu" ); // Implicit conversions yield return ( - new ImageReference( new Repository( "ubuntu" ), Tag.Latest, Registry.DockerHub, "library" ), + new PartialImageRef( Registry.DockerHub, "library", new Repository( "ubuntu" ), Tag.Latest ), "docker.io/library/ubuntu:latest" ); // Custom namespace on DockerHub yield return ( - new ImageReference( "drift", Tag.Latest, @namespace: "hojmark" ), + new PartialImageRef( new Namespace( "hojmark" ), "drift", Tag.Latest ), "docker.io/hojmark/drift:latest" ); yield return ( - new ImageReference( "drift", new SemVersion( 1, 21, 1 ), @namespace: "hojmark" ), + new PartialImageRef( new Namespace( "hojmark" ), "drift", new SemVersion( 1, 21, 1 ) ), "docker.io/hojmark/drift:1.21.1" ); yield return ( - new ImageReference( "drift", new SemVersion( 1, 21, 1 ), Registry.DockerHub, "hojmark" ), + new PartialImageRef( Registry.DockerHub, "hojmark", "drift", new SemVersion( 1, 21, 1 ) ), "docker.io/hojmark/drift:1.21.1" ); // Localhost (no namespace) yield return ( - new ImageReference( new Repository( "drift" ), Tag.Latest, Registry.Localhost ), + new PartialImageRef( Registry.Localhost, new Repository( "drift" ), Tag.Latest ), "localhost:5000/drift:latest" ); yield return ( - new ImageReference( new Repository( "drift" ), Tag.Dev, Registry.Localhost ), + new PartialImageRef( Registry.Localhost, new Repository( "drift" ), Tag.Dev ), "localhost:5000/drift:dev" ); yield return ( - new ImageReference( new Repository( "drift" ), new SemVersion( 2, 0, 0 ), Registry.Localhost ), + 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 ImageReference( "myapp", Tag.Latest, Registry.GitHub, "myorg" ), + new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ), "ghcr.io/myorg/myapp:latest" ); yield return ( - new ImageReference( "myapp", Tag.Latest, Registry.Quay, "myorg" ), + new PartialImageRef( Registry.Quay, "myorg", "myapp", Tag.Latest ), "quay.io/myorg/myapp:latest" ); - // With digest + // With digest and tag yield return ( - new ImageReference( "nginx", Tag.Latest, Registry.DockerHub, "library", new Digest( "sha256:abc123" ) ), + new PartialImageRef( Registry.DockerHub, "library", "nginx", Tag.Latest, new Digest( "sha256:abc123" ) ), "docker.io/library/nginx:latest@sha256:abc123" ); + // With digest only (no tag) + yield return ( + new PartialImageRef( "nginx", digest: new Digest( "sha256:abc123" ) ), + "docker.io/library/nginx@sha256:abc123" + ); } } [Test] [MethodDataSource( nameof(ToStringCases) )] - public async Task ToStringTest( ImageReference reference, string expected ) { + public async Task ToStringTest( PartialImageRef reference, string expected ) { await Assert.That( reference.ToString() ).IsEqualTo( expected ); } // ----------------------- // Parse round-trip // ----------------------- - public static IEnumerable<(string, string)> ParseCases { + 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@sha256:abc123", "docker.io/library/nginx@sha256:abc123" ); + // Tag + digest + yield return ( "docker.io/library/nginx:1.25@sha256:abc123", "docker.io/library/nginx:1.25@sha256:abc123" ); + } + } + + [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", "docker.io/library/ubuntu:latest" ); yield return ( "docker.io/library/ubuntu", "docker.io/library/ubuntu:latest" ); + // 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@sha256:abc123", "docker.io/library/nginx@sha256:abc123" ); + // Tag + digest + yield return ( "docker.io/library/nginx:1.25@sha256:abc123", "docker.io/library/nginx:1.25@sha256:abc123" ); } } [Test] - [MethodDataSource( nameof(ParseCases) )] - public async Task ParseRoundTripTest( string input, string expected ) { - var parsed = ImageReference.Parse( input ); + [MethodDataSource( nameof(ParseCanonicalCases) )] + public async Task ParseCanonicalTest( string input, string expected ) { + var parsed = input.Image(); await Assert.That( parsed.ToString() ).IsEqualTo( expected ); } @@ -109,13 +150,12 @@ public async Task ParseRoundTripTest( string input, string expected ) { // ----------------------- [Test] public void ParseNullThrows() { - Assert.Throws( () => ImageReference.Parse( null! ) ); + Assert.Throws( () => PartialImageRef.Parse( null! ) ); } [Test] public void ParseEmptyThrows() { - // Empty string will either fail regex or fail Repository validation - Assert.Throws( () => ImageReference.Parse( string.Empty ) ); + Assert.Throws( () => PartialImageRef.Parse( string.Empty ) ); } // ----------------------- @@ -123,7 +163,7 @@ public void ParseEmptyThrows() { // ----------------------- [Test] public async Task TryParseValidInputReturnsTrue() { - var success = ImageReference.TryParse( "docker.io/library/nginx:1.25", out var result ); + 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" ); @@ -131,25 +171,131 @@ public async Task TryParseValidInputReturnsTrue() { [Test] public async Task TryParseNullReturnsFalse() { - var success = ImageReference.TryParse( null, out var result ); + 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(); + } + + // ----------------------- + // IsCanonical + // ----------------------- + [Test] + public async Task IsCanonicalFalseWhenNoTagAndNoDigest() { + var image = new PartialImageRef( "nginx" ); + await Assert.That( image.IsQualified ).IsFalse(); + } + + [Test] + public async Task IsCanonicalTrueWhenTagPresent() { + var image = new PartialImageRef( "nginx", Tag.Latest ); + await Assert.That( image.IsQualified ).IsFalse(); + await Assert.That( image.CanQualify ).IsTrue(); + + var qualified = image.Qualify(); + await Assert.That( qualified.IsQualified ).IsTrue(); + await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); + // await Assert.That( canonical.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + } + + [Test] + public async Task IsCanonicalTrueWhenDigestPresent() { + var image = new PartialImageRef( "nginx", digest: new Digest( "sha256:abc123" ) ); + await Assert.That( image.IsQualified ).IsFalse(); + await Assert.That( image.CanQualify ).IsTrue(); + + var qualified = image.Qualify(); + await Assert.That( qualified.IsQualified ).IsTrue(); + // await Assert.That( canonical.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( qualified.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx@sha256:abc123" ); + } + + [Test] + public async Task IsCanonicalTrueWhenBothTagAndDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + await Assert.That( image.IsQualified ).IsFalse(); + await Assert.That( image.CanQualify ).IsTrue(); + + var qualified = image.Qualify(); + await Assert.That( qualified.IsQualified ).IsTrue(); + await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); + await Assert.That( qualified.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); + await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); + await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx:latest@sha256:abc123" ); + } + + // ----------------------- + // IsPinned + // ----------------------- + [Test] + public async Task IsImmutableFalseWhenNoDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest ); + await Assert.That( image.IsPinned ).IsFalse(); + } + + [Test] + public async Task IsImmutableFalseWhenNoTagAndNoDigest() { + var image = new PartialImageRef( "nginx" ); + await Assert.That( image.IsPinned ).IsFalse(); + } + + [Test] + public async Task IsImmutableTrueWhenDigestPresent() { + var image = new PartialImageRef( "nginx", digest: new Digest( "sha256:abc123" ) ); + await Assert.That( image.IsPinned ).IsTrue(); + } + + [Test] + public async Task IsImmutableTrueWhenBothTagAndDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + await Assert.That( image.IsPinned ).IsTrue(); + } + // ----------------------- // Equality (record semantics) // ----------------------- [Test] public async Task EqualReferencesAreEqual() { - var a = new ImageReference( "nginx", Tag.Latest ); - var b = new ImageReference( "nginx", Tag.Latest ); + 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 ImageReference( "nginx", Tag.Latest ); - var b = new ImageReference( "nginx", Tag.Dev ); + 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 ); } @@ -158,17 +304,17 @@ public async Task DifferentTagsAreNotEqual() { // ----------------------- [Test] public void EmptyRepositoryThrows() { - Assert.Throws( () => new ImageReference( new Repository( string.Empty ) ) ); + Assert.Throws( () => new PartialImageRef( new Repository( string.Empty ) ) ); } [Test] public void WhitespaceRepositoryThrows() { - Assert.Throws( () => new ImageReference( new Repository( " " ) ) ); + Assert.Throws( () => new PartialImageRef( new Repository( " " ) ) ); } [Test] public void RepositoryWithSlashThrows() { - Assert.Throws( () => new ImageReference( new Repository( "a/b" ) ) ); + Assert.Throws( () => new PartialImageRef( new Repository( "a/b" ) ) ); } // ----------------------- @@ -176,15 +322,23 @@ public void RepositoryWithSlashThrows() { // ----------------------- [Test] public async Task WithTagReplacesTag() { - var original = new ImageReference( "nginx", new SemVersion( 1, 25, 0 ) ); - var updated = original with { Tag = Tag.Latest }; - await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + 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 ImageReference( "drift", new SemVersion( 1, 0, 0 ), Registry.Localhost ); - var updated = original with { Tag = Tag.Latest }; + 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" ); @@ -192,50 +346,50 @@ public async Task WithTagPreservesOtherProperties() { [Test] public async Task WithRegistryReplacesHost() { - var original = new ImageReference( "myapp", Tag.Latest, @namespace: "team" ); - var updated = original with { Registry = Registry.GitHub }; + 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 ImageReference( "drift", Tag.Latest ); - var updated = original with { Namespace = new Namespace( "hojmark" ) }; - await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/hojmark/drift:latest" ); + 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 ImageReference( "drift", Tag.Latest, @namespace: "hojmark" ); - var updated = original with { Namespace = null }; - await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/drift:latest" ); + 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 ImageReference( "nginx", Tag.Latest ); - var updated = original with { Digest = new Digest( "sha256:abc123" ) }; - await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest@sha256:abc123" ); + var original = new PartialImageRef( "nginx", Tag.Latest ); + var updated = original.With( new Digest( "sha256:abc123" ) ); + await Assert.That( updated.ToString() ).IsEqualTo( "nginx:latest@sha256:abc123" ); } [Test] public async Task WithDigestNullRemovesDigest() { - var original = new ImageReference( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); - var updated = original with { Digest = null }; - await Assert.That( updated.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); + var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + var updated = original.With( (Digest) null! ); + await Assert.That( updated.ToString() ).IsEqualTo( "nginx:latest" ); } [Test] public async Task WithMultipleProperties() { - var original = new ImageReference( "nginx", Tag.Latest ); - var updated = original with { Tag = new SemVersion( 2, 0, 0 ), Registry = Registry.Localhost, Namespace = null }; + 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 ImageReference( "nginx", Tag.Latest ); - _ = original with { Tag = new Tag( "alpine" ) }; + 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/Containers/AGENTS.md b/dotnet/Containers/AGENTS.md new file mode 100644 index 0000000..b553404 --- /dev/null +++ b/dotnet/Containers/AGENTS.md @@ -0,0 +1,69 @@ +## 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 + +### Documentation + +- Keep usage examples up to date in project README.md +- XML docs on all public APIs with ``, ``, ``, `` being required. +- Include `` tags showing valid/invalid inputs where appropriate. +- 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 \ No newline at end of file diff --git a/dotnet/Containers/CanonicalImageRef.cs b/dotnet/Containers/CanonicalImageRef.cs new file mode 100644 index 0000000..394d8a5 --- /dev/null +++ b/dotnet/Containers/CanonicalImageRef.cs @@ -0,0 +1,75 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HLabs.Containers; + +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] +[SuppressMessage( + "StyleCop.CSharp.MaintainabilityRules", + "SA1404:Code analysis suppression should have justification" )] +/// +/// Content-addressable (immutable) image reference. +/// +public sealed record CanonicalImageRef : ImageRef { + public new Registry Registry { + get; + } + + /* public Namespace? Namespace { + get; + }*/ + + /*public Repository Repository { + get; + }*/ + + public new Digest Digest { + get; + } + + /*public Tag? Tag { + get; + } // optional cosmetic / informational tag + */ + + internal CanonicalImageRef( + Registry registry, + Namespace? ns, + Repository repository, + Digest digest, + Tag? tag = null + ) : base( repository ) { + Registry = registry ?? throw new ArgumentNullException( nameof(registry) ); + Namespace = ns ?? ( Registry.NamespaceRequired ? throw new ArgumentNullException( nameof(ns) ) : null ); + Digest = digest ?? throw new ArgumentNullException( nameof(digest) ); + Tag = tag; // optional, does not affect immutability + } + + /// + /// Returns a new instance with a different cosmetic tag. + /// + public CanonicalImageRef WithTag( Tag tag ) => + new(Registry, Namespace, Repository, Digest, tag); + + /// + /// Returns a new instance with a different registry. + /// + public CanonicalImageRef On( Registry registry ) => + new(registry, Namespace, Repository, Digest, Tag); + + /// + /// Returns a new instance with a different namespace. + /// + public CanonicalImageRef WithNamespace( Namespace ns ) => + // TODO remember registry/namespace requirement + new(Registry, ns, Repository, Digest, Tag); + + public override bool IsQualified => true; + + public override string ToString() { + var nsPart = Namespace is not null ? $"{Namespace}/" : string.Empty; + return Tag is not null + ? $"{Registry}/{nsPart}{Repository}:{Tag}@{Digest}" // shows tag + digest + : $"{Registry}/{nsPart}{Repository}@{Digest}"; + } +} \ No newline at end of file diff --git a/dotnet/Containers/Digest.cs b/dotnet/Containers/Digest.cs index 53bf7b7..46c5518 100644 --- a/dotnet/Containers/Digest.cs +++ b/dotnet/Containers/Digest.cs @@ -1,6 +1,13 @@ +using System.Text.RegularExpressions; + namespace HLabs.Containers; 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; } @@ -14,11 +21,36 @@ public Digest( string value ) { throw new ArgumentException( "Digest contains leading/trailing whitespace", nameof(value) ); } - if ( !value.Contains( ':', StringComparison.Ordinal ) ) { - throw new ArgumentException( "Digest must be in 'algorithm:hex' format (e.g. 'sha256:abc123')", 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) + ); } - Value = value; + // Conventionally lowercase +#pragma warning disable CA1308 + Value = $"{DefaultAlgorithm}:{digest.ToLowerInvariant()}"; +#pragma warning restore CA1308 } public static implicit operator Digest( string value ) => FromString( value ); diff --git a/dotnet/Containers/ImageId.cs b/dotnet/Containers/ImageId.cs new file mode 100644 index 0000000..40cbd67 --- /dev/null +++ b/dotnet/Containers/ImageId.cs @@ -0,0 +1,63 @@ +using System.Text.RegularExpressions; + +namespace HLabs.Containers; + +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; + } + + 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 + } + + public static implicit operator ImageId( string value ) => FromString( value ); + + public static ImageId FromString( string value ) { + return new(value); + } + + public override string ToString() => Value; +} \ No newline at end of file diff --git a/dotnet/Containers/ImageRef.cs b/dotnet/Containers/ImageRef.cs new file mode 100644 index 0000000..098c774 --- /dev/null +++ b/dotnet/Containers/ImageRef.cs @@ -0,0 +1,44 @@ +namespace HLabs.Containers; + +public abstract record ImageRef { + protected ImageRef( Repository repository ) { + Repository = repository ?? throw new ArgumentNullException( nameof(repository) ); + } + + public Registry? Registry { + get; + protected init; + } + +#pragma warning disable CA1716 + public Namespace? Namespace { +#pragma warning restore CA1716 + get; + protected init; + } + + public Repository Repository { + get; + } + + public Tag? Tag { + get; + protected init; + } + + public Digest? Digest { + get; + protected init; + } + + /// + /// Gets a value indicating whether this reference is pinned by a . + /// + public virtual bool IsPinned => Digest is not null; + + /// + /// Gets a value indicating whether this reference can identify a specific image (has at least a tag or digest). + /// Note: a tag-based canonical reference is not pinned — the tag can be moved to a different image. + /// + public abstract bool IsQualified { get; } +} \ No newline at end of file diff --git a/dotnet/Containers/ImageReference.Parsing.cs b/dotnet/Containers/ImageReference.Parsing.cs deleted file mode 100644 index 04920a1..0000000 --- a/dotnet/Containers/ImageReference.Parsing.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Text.RegularExpressions; - -namespace HLabs.Containers; - -public sealed partial record ImageReference { - [GeneratedRegex( - @"^(?:(?[^/]+?)/)?(?:(?[^/]+?)/)?(?[^:@]+)(?::(?[^@]+))?(?:@(?.+))?$", - RegexOptions.CultureInvariant - )] - private static partial Regex ImageRefRegex(); - - public static ImageReference Parse( string imageReference ) { - ArgumentNullException.ThrowIfNull( imageReference ); - - var match = ImageRefRegex().Match( imageReference.Trim() ); - if ( !match.Success ) { - throw new FormatException( $"Invalid image reference: '{imageReference}'" ); - } - - var registryStr = match.Groups["registry"].Value; - var namespaceStr = match.Groups["namespace"].Value; - var repositoryStr = match.Groups["repository"].Value; - var tagStr = match.Groups["tag"].Value; - var digestStr = match.Groups["digest"].Value; - - var registry = string.IsNullOrEmpty( registryStr ) - ? Registry.DockerHub - : new Registry( registryStr ); - - var @namespace = string.IsNullOrEmpty( namespaceStr ) - ? ( registry == Registry.DockerHub ? Namespace.Library : null ) - : new Namespace( namespaceStr ); - - var repository = new Repository( repositoryStr ); - - var tag = string.IsNullOrEmpty( tagStr ) ? Tag.Latest : new Tag( tagStr ); - - var digest = string.IsNullOrEmpty( digestStr ) - ? null - : new Digest( digestStr ); - - return new ImageReference( repository, tag, registry, @namespace, digest ); - } - - public static bool TryParse( string? input, out ImageReference? 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/Containers/ImageReference.cs b/dotnet/Containers/ImageReference.cs deleted file mode 100644 index d4d7dc1..0000000 --- a/dotnet/Containers/ImageReference.cs +++ /dev/null @@ -1,60 +0,0 @@ -namespace HLabs.Containers; - -// TODO support platform -// TODO docs -/// -/// A fully-qualified container image reference. -/// -/// example.com:5000/team/my-app:2.0 -/// -/// Host: example.com:5000 -/// Namespace: team -/// Repository: my-app -/// Tag: 2.0 -/// -/// -public sealed partial record ImageReference { - public Registry Registry { - get; - init; - } - - public Namespace? Namespace { - get; - init; - } - - public Repository Repository { - get; - } - - public Tag Tag { - get; - init; - } - - public Digest? Digest { - get; - init; - } - - public ImageReference( - Repository repository, - Tag? tag = null, - Registry? host = null, - Namespace? @namespace = null, - Digest? digest = null - ) { - Repository = repository; - Tag = tag ?? Tag.Latest; - Registry = host ?? Registry.DockerHub; - Namespace = @namespace ?? ( Registry == Registry.DockerHub ? Namespace.Library : null ); - Digest = digest; - } - - public override string ToString() { - var ns = Namespace is null ? string.Empty : $"{Namespace}/"; - var digest = Digest is null ? string.Empty : $"@{Digest}"; - return $"{Registry}/{ns}{Repository}:{Tag}{digest}"; - } -} \ No newline at end of file diff --git a/dotnet/Containers/PartialImageRef.Parsing.cs b/dotnet/Containers/PartialImageRef.Parsing.cs new file mode 100644 index 0000000..9ab5576 --- /dev/null +++ b/dotnet/Containers/PartialImageRef.Parsing.cs @@ -0,0 +1,53 @@ +using System.Text.RegularExpressions; + +namespace HLabs.Containers; + +public sealed partial record PartialImageRef { + [GeneratedRegex( + @"^(?:(?[^/]+?)/)?(?:(?[^/]+?)/)?(?[^:@]+)(?::(?[^@]+))?(?:@(?.+))?$", + RegexOptions.CultureInvariant + )] + private static partial Regex ImageRefRegex(); + + 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 ); + } + + 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/Containers/PartialImageRef.cs b/dotnet/Containers/PartialImageRef.cs new file mode 100644 index 0000000..c477fe6 --- /dev/null +++ b/dotnet/Containers/PartialImageRef.cs @@ -0,0 +1,201 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HLabs.Containers; + +// TODO support platform +// TODO docs +/// +/// ~~A fully-qualified container image reference.~~ +/// +/// example.com:5000/team/my-app:2.0 +/// +/// Host: example.com:5000 +/// Namespace: team +/// Repository: my-app +/// Tag: 2.0 +/// +/// +[SuppressMessage( + "StyleCop.CSharp.DocumentationRules", + "SA1623:Property summary documentation should match accessors", + Justification = "Temp" +)] +[SuppressMessage( "Design", "CA1031:Do not catch general exception types", Justification = "Temp" )] +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] +[SuppressMessage( + "StyleCop.CSharp.MaintainabilityRules", + "SA1404:Code analysis suppression should have justification" )] +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 + ) : base( repository ) { + ArgumentNullException.ThrowIfNull( repository ); // TODO test this + Tag = tag; + Registry = registry; + Namespace = @namespace; + Digest = digest; + } + +#pragma warning disable SA1124 + + #region DockerHub + +#pragma warning restore SA1124 + + public PartialImageRef( Repository repository ) + : this( repository, null, null, null, null ) { + } + + public PartialImageRef( Repository repository, Tag tag ) + : this( repository, tag, null, null, null ) { + } + + public PartialImageRef( Namespace ns, Repository repository ) + : this( repository, null, null, ns, null ) { + } + + public PartialImageRef( Namespace ns, Repository repository, Tag tag ) + : this( repository, tag, null, ns, null ) { + } + + public PartialImageRef( Repository repository, Digest digest ) + : this( repository, null, null, null, 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 + public PartialImageRef( Registry registry, Repository repository ) + : this( repository, null, registry, null, null ) { + } + + // Overload clashes with Namespace + Repository + Tag when using string literals (implicit conversions), so not included for now. + // The more common use case is probably the other one. + public PartialImageRef( Registry registry, Repository repository, Tag tag ) + : this( repository, tag, registry, null, null ) { + } + + public PartialImageRef( Registry registry, Repository repository, Digest digest ) + : this( repository, null, registry, null, digest ) { + } + + public PartialImageRef( Registry registry, Namespace ns, Repository repository ) + : this( repository, null, registry, ns, null ) { + } + + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Tag tag ) + : this( repository, tag, registry, ns, null ) { + } + + public PartialImageRef( Registry registry, Repository repository, Tag tag, Digest digest ) + : this( repository, tag, registry, null, digest ) { + } + + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Tag tag, Digest digest ) + : this( repository, tag, registry, ns, digest ) { + } + + #endregion + + public override string ToString() { + var reg = Registry is null ? string.Empty : $"{Registry}/"; + var ns = Namespace is null ? string.Empty : $"{Namespace}/"; + var tag = Tag is null ? string.Empty : $":{Tag}"; + var digest = Digest is null ? string.Empty : $"@{Digest}"; + return $"{reg}{ns}{Repository}{tag}{digest}"; + } + + public bool TryQualify( out QualifiedImageRef? canonical, out string? reason ) { + try { + canonical = Qualify(); + reason = null; + return true; + } + catch ( Exception ex ) { + canonical = null; + reason = ex.Message; + return false; + } + } + + /// + /// Returns a new instance with a different cosmetic tag. + /// + public PartialImageRef With( Tag? tag ) => + new(Repository, tag, Registry, Namespace, Digest); + + /// + /// Returns a new instance with a different registry. + /// + public PartialImageRef With( Registry? registry ) => + new(Repository, Tag, registry, Namespace, Digest); + + /// + /// Returns a new instance with a different registry. + /// + public PartialImageRef With( Registry registry, Namespace ns ) => + new(Repository, Tag, registry, ns, Digest); + + /// + /// Returns a new instance with a different namespace. + /// + public PartialImageRef With( Namespace? ns ) => + // TODO remember registry/namespace requirement + new(Repository, Tag, Registry, ns, Digest); + + /// + /// Returns a new instance with a different digest. + /// + public PartialImageRef With( Digest? digest ) => + new(Repository, Tag, Registry, Namespace, digest); + + public override bool IsQualified => Registry != null && ( !Registry.NamespaceRequired || Namespace != null ) && + ( Tag != null || Digest != null ); + + public bool CanQualify => TryQualify( out _, out _ ); // TODO Bad impl + + /// + /// Converts this reference to a canonical form. + /// Attempts to fill in defaults for missing registry/namespace based on common conventions (e.g. Docker Hub defaults). + /// Either a tag or digest must be present. + /// + /// The behavior when the reference is not in a canonical form. + /// Throws InvalidOperationException if the reference is not in a canonical form. + /// A canonical image reference. + 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 ); + } + + public CanonicalImageRef Canonicalize( Digest digest, QualificationMode mode = QualificationMode.DefaultFilling ) { + return With( digest ).Qualify( mode ).Canonicalize(); + } + + public CanonicalImageRef Canonicalize( QualificationMode mode = QualificationMode.DefaultFilling ) { + return Qualify( mode ).Canonicalize(); + } +} \ No newline at end of file diff --git a/dotnet/Containers/PartialImageRefExtensions.cs b/dotnet/Containers/PartialImageRefExtensions.cs new file mode 100644 index 0000000..41216d3 --- /dev/null +++ b/dotnet/Containers/PartialImageRefExtensions.cs @@ -0,0 +1,38 @@ +namespace HLabs.Containers; + +public static class PartialImageRefExtensions { +#pragma warning disable CA1034 + extension( PartialImageRef imageRef ) { +#pragma warning restore CA1034 + /// + /// Returns a new instance with a different cosmetic tag. + /// + public QualifiedImageRef Qualify( Tag? tag ) => + imageRef.With( tag ).Qualify(); + + /// + /// Returns a new instance with a different registry. + /// + public QualifiedImageRef Qualify( Registry? registry ) => + imageRef.With( registry ).Qualify(); + + /// + /// Returns a new instance with a different registry. + /// + public QualifiedImageRef Qualify( Registry registry, Namespace ns ) => + imageRef.With( registry, ns ).Qualify(); + + /// + /// Returns a new instance with a different namespace. + /// + public QualifiedImageRef Qualify( Namespace? ns ) => + // TODO remember registry/namespace requirement + imageRef.With( ns ).Qualify(); + + /// + /// Returns a new instance with a different digest. + /// + public QualifiedImageRef Qualify( Digest? digest ) => + imageRef.With( digest ).Qualify(); + } +} \ No newline at end of file diff --git a/dotnet/Containers/QualificationMode.cs b/dotnet/Containers/QualificationMode.cs new file mode 100644 index 0000000..f92d7fe --- /dev/null +++ b/dotnet/Containers/QualificationMode.cs @@ -0,0 +1,6 @@ +namespace HLabs.Containers; + +public enum QualificationMode { + RequireAll, + DefaultFilling +} \ No newline at end of file diff --git a/dotnet/Containers/QualifiedImageRef.cs b/dotnet/Containers/QualifiedImageRef.cs new file mode 100644 index 0000000..f64040a --- /dev/null +++ b/dotnet/Containers/QualifiedImageRef.cs @@ -0,0 +1,85 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HLabs.Containers; + +// ----------------------------- +// Canonical image reference: fully qualified (registry, namespace, repository, tag or digest) +// ----------------------------- +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] +[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] +[SuppressMessage( + "StyleCop.CSharp.MaintainabilityRules", + "SA1404:Code analysis suppression should have justification" )] +/// +/// Mutable image reference. +/// Can adress an image, but the underlying image may change. +/// (registry, optional- depends on registy namespace, repository, tag or digest) +/// +public sealed record QualifiedImageRef : ImageRef { + public new Registry Registry { + get; + } + + /* + public Namespace? Namespace { + get; + } // only required if the registry requires it + */ + +/* +public Tag? Tag { + get; +} // nullable if digest-only + +public Digest? Digest { + get; +} // nullable if tag-only +*/ + internal QualifiedImageRef( Registry registry, Namespace? ns, Repository repository, Tag? tag, Digest? digest ) : + base( repository ) { + Registry = registry ?? throw new ArgumentNullException( nameof(registry) ); + Namespace = ns ?? ( Registry.NamespaceRequired ? throw new ArgumentNullException( nameof(ns) ) : null ); + + 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 a different tag. + /// + public QualifiedImageRef WithTag( Tag tag ) => + new(Registry, Namespace, Repository, tag, Digest); + + /// + /// Returns a new instance with a different registry. + /// + public QualifiedImageRef On( Registry registry, Namespace? ns = null ) => + new(registry, ns ?? Namespace, Repository, Tag, Digest); + + /// + /// Creates a pinned image reference. + /// + public CanonicalImageRef Canonicalize( Digest digest ) { + ArgumentNullException.ThrowIfNull( digest ); + + return new CanonicalImageRef( Registry, Namespace, Repository, digest ); + } + + public CanonicalImageRef Canonicalize() { + // ! TODO: + return Canonicalize( Digest! ); + } + + public override bool IsQualified => true; + + 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/Containers/README.md b/dotnet/Containers/README.md index 9414018..d3dfc3b 100644 --- a/dotnet/Containers/README.md +++ b/dotnet/Containers/README.md @@ -18,7 +18,7 @@ dotnet add package HLabs.Containers ```csharp var image = new ImageReference("nginx"); -Console.WriteLine(image); // docker.io/library/nginx:latest +Console.WriteLine(image); // docker.io/library/nginx ``` ### Building references @@ -32,6 +32,26 @@ var image = new ImageReference(new Repository("nginx"), new Tag("trixie")); // // Semantic Versioning support (via the Semver package) var image = new ImageReference("myapp", new SemVersion(3, 1, 0), Registry.GitHub, "myorg"); // → ghcr.io/myorg/myapp:3.1.0 + + + + + +// Minimal — no tag, no digest +var image = new ImageReference("nginx"); // → docker.io/library/nginx + +// With an explicit tag +var image = new ImageReference("nginx", Tag.Latest); // → docker.io/library/nginx:latest var image = new ImageReference("nginx", "trixie"); // → docker.io/library/nginx:trixie + +// Digest-only (no tag) +var image = new ImageReference("nginx", digest: new Digest("sha256:a3ed95caeb02...")); // → docker.io/library/nginx@sha256:a3ed95caeb02... + +// Tag + digest +var image = new ImageReference("nginx", "trixie", digest: new Digest("sha256:a3ed95caeb02...")); // → docker.io/library/nginx:trixie@sha256:a3ed95caeb02... + +// Semantic Versioning support (via the Semver package) +var image = new ImageReference("myapp", new SemVersion(3, 1, 0), Registry.GitHub, "myorg"); // → ghcr.io/myorg/myapp:3.1.0 + ``` ### Parsing references @@ -42,6 +62,27 @@ Console.WriteLine(image.Registry); // ghcr.io Console.WriteLine(image.Namespace); // myorg Console.WriteLine(image.Repository); // myapp Console.WriteLine(image.Tag); // 3.1.0 + +// Tag is null when not present in the input +var image = ImageReference.Parse("docker.io/library/nginx@sha256:abc123"); +Console.WriteLine(image.Tag); // (null) +Console.WriteLine(image.Digest); // sha256:abc123 +``` + +### `IsCanonical` and `IsImmutable` + +```csharp +var bare = new ImageReference("nginx"); // no tag, no digest +bare.IsCanonical; // false — not enough info to identify a specific image +bare.IsImmutable; // false + +var tagged = new ImageReference("nginx", Tag.Latest); // tag only +tagged.IsCanonical; // true — tag resolves to an image (but may change over time) +tagged.IsImmutable; // false — tags are mutable; "latest" can point to different images + +var pinned = new ImageReference("nginx", digest: new Digest("sha256:abc123")); // digest only +pinned.IsCanonical; // true +pinned.IsImmutable; // true — digests are content-addressable and fixed ``` ### Modifying using `with` expressions diff --git a/dotnet/Containers/Registry.Instances.cs b/dotnet/Containers/Registry.Instances.cs index 8235c0a..262af8e 100644 --- a/dotnet/Containers/Registry.Instances.cs +++ b/dotnet/Containers/Registry.Instances.cs @@ -1,10 +1,10 @@ namespace HLabs.Containers; public sealed partial record Registry { - public static readonly Registry DockerHub = new("docker.io"); - public static readonly Registry Quay = new("quay.io"); - public static readonly Registry GitHub = new("ghcr.io"); - public static readonly Registry Localhost = new("localhost:5000"); + public static readonly Registry DockerHub = new("docker.io", true); + public static readonly Registry Quay = new("quay.io", true); + public static readonly Registry GitHub = new("ghcr.io", true); + public static readonly Registry Localhost = new("localhost:5000", false); /// /// Azure Container Registry. diff --git a/dotnet/Containers/Registry.cs b/dotnet/Containers/Registry.cs index f54bf06..604a507 100644 --- a/dotnet/Containers/Registry.cs +++ b/dotnet/Containers/Registry.cs @@ -5,7 +5,11 @@ private string Host { get; } - public Registry( string host ) { + internal bool NamespaceRequired { + get; + } + + public Registry( string host, bool namespaceRequired = false ) { if ( string.IsNullOrWhiteSpace( host ) ) { throw new ArgumentException( "Registry host cannot be null or empty", nameof(host) ); } @@ -15,6 +19,7 @@ public Registry( string host ) { } Host = host; + NamespaceRequired = namespaceRequired; } public static implicit operator Registry( string host ) => FromString( host ); diff --git a/dotnet/Containers/StringExtensions.cs b/dotnet/Containers/StringExtensions.cs new file mode 100644 index 0000000..e94ed68 --- /dev/null +++ b/dotnet/Containers/StringExtensions.cs @@ -0,0 +1,24 @@ +namespace HLabs.Containers; + +public static class StringExtensions { +#pragma warning disable CA1034 + extension( string imageReference ) { +#pragma warning restore CA1034 + /// + /// Creates an unqualified container image reference. + /// + public PartialImageRef Image() => PartialImageRef.Parse( imageReference ); +/* + /// + /// Creates a qualified container image reference. + /// + /// Thrown if the image reference is not in a canonical form. + public QualifiedImageRef QualifiedImage() => PartialImageRef.Parse( imageReference ).Qualify(); + + /// + /// Creates a pinned container image reference. That is, a reference that is guaranteed to resolve to the same image (content-addressable). + /// This is only possible if the image reference has a digest. + /// + public CanonicalImageRef CanonicalImage() => PartialImageRef.Parse( imageReference ).Canonicalize();*/ + } +} \ No newline at end of file diff --git a/dotnet/Containers/Tag.cs b/dotnet/Containers/Tag.cs index f3fb692..1283be4 100644 --- a/dotnet/Containers/Tag.cs +++ b/dotnet/Containers/Tag.cs @@ -21,7 +21,7 @@ public Tag( string value ) { public static implicit operator Tag( string tag ) => FromString( tag ); - public static implicit operator Tag( SemVersion v ) => FromSemVersion( v ); + public static implicit operator Tag( SemVersion v ) => FromSemVersion( v ); // TODO move to subtype? public static Tag FromString( string tag ) { return new(tag); From deb5ec0b1a7a0d8d9ab2af2289174073ad9ac33f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:32:40 +0100 Subject: [PATCH 04/36] fix tests --- dotnet/Containers.Tests/DigestTests.cs | 22 +++---- .../Containers.Tests/ImageReferenceTests.cs | 58 ++++++++++--------- dotnet/Containers.Tests/RegistryTests.cs | 2 +- dotnet/Containers/Digest.cs | 2 +- dotnet/Containers/Namespace.cs | 5 +- dotnet/Containers/Registry.cs | 5 +- dotnet/Containers/Repository.cs | 5 +- dotnet/Containers/Tag.cs | 5 +- 8 files changed, 60 insertions(+), 44 deletions(-) diff --git a/dotnet/Containers.Tests/DigestTests.cs b/dotnet/Containers.Tests/DigestTests.cs index 4782221..d2c3821 100644 --- a/dotnet/Containers.Tests/DigestTests.cs +++ b/dotnet/Containers.Tests/DigestTests.cs @@ -1,10 +1,12 @@ namespace HLabs.Containers.Tests; internal sealed class DigestTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + [Test] public async Task ConstructorSetsValue() { - var digest = new Digest( "sha256:abc123" ); - await Assert.That( digest.ToString() ).IsEqualTo( "sha256:abc123" ); + var digest = new Digest( ValidDigest ); + await Assert.That( digest.ToString() ).IsEqualTo( ValidDigest ); } [Test] @@ -34,27 +36,27 @@ public void MissingColonThrows() { [Test] public async Task ImplicitConversionFromString() { - Digest d = "sha256:abc"; - await Assert.That( d.ToString() ).IsEqualTo( "sha256:abc" ); + Digest d = ValidDigest; + await Assert.That( d.ToString() ).IsEqualTo( ValidDigest ); } [Test] public async Task ExplicitConversionFromString() { - var d = Digest.FromString( "sha256:abc" ); - await Assert.That( d.ToString() ).IsEqualTo( "sha256:abc" ); + var d = Digest.FromString( ValidDigest ); + await Assert.That( d.ToString() ).IsEqualTo( ValidDigest ); } [Test] public async Task EqualDigestsAreEqual() { - var a = new Digest( "sha256:abc" ); - var b = new Digest( "sha256:abc" ); + 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( "sha256:abc" ); - var b = new Digest( "sha256:def" ); + 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/Containers.Tests/ImageReferenceTests.cs b/dotnet/Containers.Tests/ImageReferenceTests.cs index 43d27e6..2dcb707 100644 --- a/dotnet/Containers.Tests/ImageReferenceTests.cs +++ b/dotnet/Containers.Tests/ImageReferenceTests.cs @@ -3,6 +3,8 @@ namespace HLabs.Containers.Tests; internal sealed class ImageReferenceTests { + private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; + // ----------------------- // ToString round-trip // ----------------------- @@ -20,20 +22,20 @@ internal sealed class ImageReferenceTests { ); yield return ( new PartialImageRef( Registry.DockerHub, new Repository( "ubuntu" ), Tag.Latest ), - "docker.io/library/ubuntu:latest" + "docker.io/ubuntu:latest" ); yield return ( new PartialImageRef( new Repository( "ubuntu" ), Tag.Latest ), - "docker.io/library/ubuntu:latest" + "ubuntu:latest" ); // No tag — no :latest in output yield return ( new PartialImageRef( new Repository( "ubuntu" ) ), - "docker.io/library/ubuntu" + "ubuntu" ); yield return ( new PartialImageRef( "ubuntu" ), - "docker.io/library/ubuntu" + "ubuntu" ); // Implicit conversions yield return ( @@ -43,11 +45,11 @@ internal sealed class ImageReferenceTests { // Custom namespace on DockerHub yield return ( new PartialImageRef( new Namespace( "hojmark" ), "drift", Tag.Latest ), - "docker.io/hojmark/drift:latest" + "hojmark/drift:latest" ); yield return ( new PartialImageRef( new Namespace( "hojmark" ), "drift", new SemVersion( 1, 21, 1 ) ), - "docker.io/hojmark/drift:1.21.1" + "hojmark/drift:1.21.1" ); yield return ( new PartialImageRef( Registry.DockerHub, "hojmark", "drift", new SemVersion( 1, 21, 1 ) ), @@ -77,13 +79,13 @@ internal sealed class ImageReferenceTests { ); // With digest and tag yield return ( - new PartialImageRef( Registry.DockerHub, "library", "nginx", Tag.Latest, new Digest( "sha256:abc123" ) ), - "docker.io/library/nginx:latest@sha256:abc123" + 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( "sha256:abc123" ) ), - "docker.io/library/nginx@sha256:abc123" + new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ), + $"nginx@{ValidDigest}" ); } } @@ -108,9 +110,9 @@ public async Task ToStringTest( PartialImageRef reference, string expected ) { 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@sha256:abc123", "docker.io/library/nginx@sha256:abc123" ); + yield return ( $"docker.io/library/nginx@{ValidDigest}", $"docker.io/library/nginx@{ValidDigest}" ); // Tag + digest - yield return ( "docker.io/library/nginx:1.25@sha256:abc123", "docker.io/library/nginx:1.25@sha256:abc123" ); + yield return ( $"docker.io/library/nginx:1.25@{ValidDigest}", $"docker.io/library/nginx:1.25@{ValidDigest}" ); } } @@ -124,17 +126,17 @@ public async Task ParseNonCanonicalTest( string input, string expected ) { public static IEnumerable<(string, string)> ParseCanonicalCases { get { // No tag in input → no tag in output - yield return ( "ubuntu", "docker.io/library/ubuntu:latest" ); - yield return ( "docker.io/library/ubuntu", "docker.io/library/ubuntu:latest" ); + 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@sha256:abc123", "docker.io/library/nginx@sha256:abc123" ); + yield return ( $"docker.io/library/nginx@{ValidDigest}", $"docker.io/library/nginx@{ValidDigest}" ); // Tag + digest - yield return ( "docker.io/library/nginx:1.25@sha256:abc123", "docker.io/library/nginx:1.25@sha256:abc123" ); + yield return ( $"docker.io/library/nginx:1.25@{ValidDigest}", $"docker.io/library/nginx:1.25@{ValidDigest}" ); } } @@ -209,7 +211,7 @@ public async Task IsCanonicalTrueWhenTagPresent() { var qualified = image.Qualify(); await Assert.That( qualified.IsQualified ).IsTrue(); await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); - // await Assert.That( canonical.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + // await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); @@ -218,34 +220,34 @@ public async Task IsCanonicalTrueWhenTagPresent() { [Test] public async Task IsCanonicalTrueWhenDigestPresent() { - var image = new PartialImageRef( "nginx", digest: new Digest( "sha256:abc123" ) ); + var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ); await Assert.That( image.IsQualified ).IsFalse(); await Assert.That( image.CanQualify ).IsTrue(); var qualified = image.Qualify(); await Assert.That( qualified.IsQualified ).IsTrue(); // await Assert.That( canonical.Tag ).IsEqualTo( Tag.Latest ); - await Assert.That( qualified.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + await Assert.That( qualified.Digest ).IsEqualTo( new Digest( ValidDigest ) ); await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); - await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx@sha256:abc123" ); + await Assert.That( qualified.ToString() ).IsEqualTo( $"docker.io/library/nginx@{ValidDigest}" ); } [Test] public async Task IsCanonicalTrueWhenBothTagAndDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ); await Assert.That( image.IsQualified ).IsFalse(); await Assert.That( image.CanQualify ).IsTrue(); var qualified = image.Qualify(); await Assert.That( qualified.IsQualified ).IsTrue(); await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); - await Assert.That( qualified.Digest ).IsEqualTo( new Digest( "sha256:abc123" ) ); + await Assert.That( qualified.Digest ).IsEqualTo( new Digest( ValidDigest ) ); await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); - await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx:latest@sha256:abc123" ); + await Assert.That( qualified.ToString() ).IsEqualTo( $"docker.io/library/nginx:latest@{ValidDigest}" ); } // ----------------------- @@ -265,13 +267,13 @@ public async Task IsImmutableFalseWhenNoTagAndNoDigest() { [Test] public async Task IsImmutableTrueWhenDigestPresent() { - var image = new PartialImageRef( "nginx", digest: new Digest( "sha256:abc123" ) ); + var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ); await Assert.That( image.IsPinned ).IsTrue(); } [Test] public async Task IsImmutableTrueWhenBothTagAndDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( "sha256:abc123" ) ); + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ); await Assert.That( image.IsPinned ).IsTrue(); } @@ -368,13 +370,13 @@ public async Task WithNamespaceNullRemovesNamespace() { [Test] public async Task WithDigestAddsDigest() { var original = new PartialImageRef( "nginx", Tag.Latest ); - var updated = original.With( new Digest( "sha256:abc123" ) ); - await Assert.That( updated.ToString() ).IsEqualTo( "nginx:latest@sha256:abc123" ); + 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( "sha256:abc123" ) ); + 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" ); } diff --git a/dotnet/Containers.Tests/RegistryTests.cs b/dotnet/Containers.Tests/RegistryTests.cs index 1544083..35d2f1f 100644 --- a/dotnet/Containers.Tests/RegistryTests.cs +++ b/dotnet/Containers.Tests/RegistryTests.cs @@ -58,7 +58,7 @@ public async Task EcrFactory() { [Test] public async Task EqualRegistriesAreEqual() { - var a = new Registry( "docker.io" ); + 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/Containers/Digest.cs b/dotnet/Containers/Digest.cs index 46c5518..072f94e 100644 --- a/dotnet/Containers/Digest.cs +++ b/dotnet/Containers/Digest.cs @@ -31,7 +31,7 @@ public Digest( string value ) { if ( !algorithm.Equals( DefaultAlgorithm, StringComparison.OrdinalIgnoreCase ) ) { throw new ArgumentException( - $"Unsupported image ID algorithm '{algorithm}'. Only sha256 is supported.", + $"Unsupported digest algorithm '{algorithm}'. Only sha256 is supported.", nameof(value) ); } diff --git a/dotnet/Containers/Namespace.cs b/dotnet/Containers/Namespace.cs index daaf3b8..c1045e4 100644 --- a/dotnet/Containers/Namespace.cs +++ b/dotnet/Containers/Namespace.cs @@ -25,7 +25,10 @@ public Namespace( string name ) { throw new ArgumentException( "Namespace contains leading/trailing whitespace", nameof(name) ); } - Name = name; + // Namespace names are conventionally lowercase +#pragma warning disable CA1308 + Name = name.ToLowerInvariant(); +#pragma warning restore CA1308 } public static implicit operator Namespace( string value ) => FromString( value ); diff --git a/dotnet/Containers/Registry.cs b/dotnet/Containers/Registry.cs index 604a507..0ff541a 100644 --- a/dotnet/Containers/Registry.cs +++ b/dotnet/Containers/Registry.cs @@ -18,7 +18,10 @@ public Registry( string host, bool namespaceRequired = false ) { throw new ArgumentException( "Registry host contains leading/trailing whitespace", nameof(host) ); } - Host = host; + // Registry hosts are conventionally lowercase +#pragma warning disable CA1308 + Host = host.ToLowerInvariant(); +#pragma warning restore CA1308 NamespaceRequired = namespaceRequired; } diff --git a/dotnet/Containers/Repository.cs b/dotnet/Containers/Repository.cs index 00582f0..4f8945c 100644 --- a/dotnet/Containers/Repository.cs +++ b/dotnet/Containers/Repository.cs @@ -18,7 +18,10 @@ public Repository( string name ) { throw new ArgumentException( "Repository contains leading/trailing whitespace", nameof(name) ); } - Name = name; + // Repository names are conventionally lowercase +#pragma warning disable CA1308 + Name = name.ToLowerInvariant(); +#pragma warning restore CA1308 } public static implicit operator Repository( string value ) => FromString( value ); diff --git a/dotnet/Containers/Tag.cs b/dotnet/Containers/Tag.cs index 1283be4..662690d 100644 --- a/dotnet/Containers/Tag.cs +++ b/dotnet/Containers/Tag.cs @@ -16,7 +16,10 @@ public Tag( string value ) { throw new ArgumentException( "Tag contains leading/trailing whitespace", nameof(value) ); } - Value = value; + // Tags are conventionally lowercase +#pragma warning disable CA1308 + Value = value.ToLowerInvariant(); +#pragma warning restore CA1308 } public static implicit operator Tag( string tag ) => FromString( tag ); From 25b109d3241bee75252512b8237333d73870b5a4 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:44:32 +0100 Subject: [PATCH 05/36] f --- dotnet/Containers/AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/dotnet/Containers/AGENTS.md b/dotnet/Containers/AGENTS.md index b553404..73693c1 100644 --- a/dotnet/Containers/AGENTS.md +++ b/dotnet/Containers/AGENTS.md @@ -62,6 +62,7 @@ dotnet/ - Keep usage examples up to date in project README.md - XML docs on all public APIs with ``, ``, ``, `` being required. + - Private or internal types and 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. - Reference OCI spec sections where applicable - Document format specifications clearly From 7ee1aa153f5809745f1c0a501a3f4a522a820f86 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:09:31 +0100 Subject: [PATCH 06/36] docs --- .../DockerLoginSettingsExtensions.cs | 9 ++++ .../DockerPushSettingsExtensions.cs | 9 ++++ .../DockerTagSettingsExtensions.cs | 47 ++++++++++++++---- .../ExtensionsMore.cs | 4 +- dotnet/Containers/AGENTS.md | 9 +++- dotnet/Containers/CanonicalImageRef.cs | 22 +-------- dotnet/Containers/Digest.cs | 39 +++++++++++++++ dotnet/Containers/ImageId.cs | 30 ++++++++++++ dotnet/Containers/ImageRef.cs | 29 ++++++++++- dotnet/Containers/Namespace.cs | 31 ++++++++++++ dotnet/Containers/PartialImageRef.cs | 14 +----- .../Containers/PartialImageRefExtensions.cs | 25 +++++++--- dotnet/Containers/QualificationMode.cs | 10 ++++ dotnet/Containers/QualifiedImageRef.cs | 43 ++++++----------- dotnet/Containers/Registry.Instances.cs | 15 ++++++ dotnet/Containers/Registry.cs | 31 ++++++++++++ dotnet/Containers/Repository.cs | 30 ++++++++++++ dotnet/Containers/StringExtensions.cs | 14 +++++- dotnet/Containers/Tag.Instances.cs | 3 ++ dotnet/Containers/Tag.cs | 48 +++++++++++++++++-- 20 files changed, 376 insertions(+), 86 deletions(-) diff --git a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs index 8425ae4..d18239a 100644 --- a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs +++ b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -2,7 +2,16 @@ namespace HLabs.Containers.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() ); diff --git a/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs index fc84d12..bf95021 100644 --- a/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs +++ b/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs @@ -2,7 +2,16 @@ namespace HLabs.Containers.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() ); diff --git a/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs index 83b0c95..3c47fc4 100644 --- a/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs +++ b/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs @@ -2,29 +2,60 @@ namespace HLabs.Containers.Extensions.Nuke; +/// +/// Extension methods for to work with strongly-typed image references. +/// public static class DockerTagSettingsExtensions { - /*public static DockerBuildSettings SetTag( this DockerBuildSettings settings, QualifiedImageRef imageReference ) { - ArgumentNullException.ThrowIfNull( imageReference ); - return DockerBuildSettingsExtensions.SetTag( settings, imageReference.ToString() ); - }*/ - + /// + /// 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() ); + 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() ); + 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() ); + return global::Nuke.Common.Tools.Docker.DockerTagSettingsExtensions.SetTargetImage( + settings, + imageReference.ToString() + ); } } \ No newline at end of file diff --git a/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs index 05ca69c..6503349 100644 --- a/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs +++ b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs @@ -23,9 +23,7 @@ private static Digest GetDigestForImageId( string[] lines, ImageId targetImageId foreach ( var line in lines ) { var parts = line.Split( ' ', 2 ); if ( parts.Length != 2 ) { -#pragma warning disable CA2201 - throw new Exception( $"Unexcepted line from docker image ls: '{line}'" ); -#pragma warning restore CA2201 + throw new FormatException( $"Unexpected line from docker image ls: '{line}'" ); } var imageId = parts[0]; diff --git a/dotnet/Containers/AGENTS.md b/dotnet/Containers/AGENTS.md index 73693c1..d0e53da 100644 --- a/dotnet/Containers/AGENTS.md +++ b/dotnet/Containers/AGENTS.md @@ -62,9 +62,14 @@ dotnet/ - Keep usage examples up to date in project README.md - XML docs on all public APIs with ``, ``, ``, `` being required. - - Private or internal types and methods do not need XML docs but should still be well-commented if their logic is complex. + - If a parameter is non-nullable but is still checked for null, `` should + not be documented. + - `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 \ No newline at end of file +- Use Markdown for README and other documentation files +- Use "container image" over "Docker image" \ No newline at end of file diff --git a/dotnet/Containers/CanonicalImageRef.cs b/dotnet/Containers/CanonicalImageRef.cs index 394d8a5..782ea71 100644 --- a/dotnet/Containers/CanonicalImageRef.cs +++ b/dotnet/Containers/CanonicalImageRef.cs @@ -1,37 +1,18 @@ -using System.Diagnostics.CodeAnalysis; - namespace HLabs.Containers; -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] -[SuppressMessage( - "StyleCop.CSharp.MaintainabilityRules", - "SA1404:Code analysis suppression should have justification" )] /// /// Content-addressable (immutable) image reference. +/// Guaranteed to resolve to the same image content due to digest pinning. /// public sealed record CanonicalImageRef : ImageRef { public new Registry Registry { get; } - /* public Namespace? Namespace { - get; - }*/ - - /*public Repository Repository { - get; - }*/ - public new Digest Digest { get; } - /*public Tag? Tag { - get; - } // optional cosmetic / informational tag - */ - internal CanonicalImageRef( Registry registry, Namespace? ns, @@ -61,7 +42,6 @@ public CanonicalImageRef On( Registry registry ) => /// Returns a new instance with a different namespace. /// public CanonicalImageRef WithNamespace( Namespace ns ) => - // TODO remember registry/namespace requirement new(Registry, ns, Repository, Digest, Tag); public override bool IsQualified => true; diff --git a/dotnet/Containers/Digest.cs b/dotnet/Containers/Digest.cs index 072f94e..c15a7fe 100644 --- a/dotnet/Containers/Digest.cs +++ b/dotnet/Containers/Digest.cs @@ -2,6 +2,26 @@ namespace HLabs.Containers; +/// +/// Represents a digest for a container image e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4. +///

+/// Digests are immutable identifiers that uniquely identify image content i.e., they are content-addressable. +///

+///
+/// +/// Using implicit string conversion: +/// +/// Digest digest = "sha256:abc123"; +/// +/// Using constructor +/// +/// var digest = new Digest("sha256:abc123"); +/// +/// Algorithm prefix is optional: +/// +/// Digest digest1 = "abc123"; +/// +/// public sealed record Digest { private const string DefaultAlgorithm = "sha256"; @@ -12,6 +32,11 @@ 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 not a valid digest. public Digest( string value ) { if ( string.IsNullOrWhiteSpace( value ) ) { throw new ArgumentException( "Digest cannot be null or empty", nameof(value) ); @@ -53,11 +78,25 @@ public Digest( string value ) { #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 not a valid digest. public static Digest FromString( string value ) { return new(value); } + /// + /// Returns the string representation of this digest. + /// + /// The digest in format sha256:abc123. public override string ToString() => Value; } \ No newline at end of file diff --git a/dotnet/Containers/ImageId.cs b/dotnet/Containers/ImageId.cs index 40cbd67..937c76b 100644 --- a/dotnet/Containers/ImageId.cs +++ b/dotnet/Containers/ImageId.cs @@ -2,6 +2,17 @@ namespace HLabs.Containers; +/// +/// 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"; @@ -12,6 +23,11 @@ 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) ); @@ -53,11 +69,25 @@ public ImageId( string value ) { #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/Containers/ImageRef.cs b/dotnet/Containers/ImageRef.cs index 098c774..fd1ac5f 100644 --- a/dotnet/Containers/ImageRef.cs +++ b/dotnet/Containers/ImageRef.cs @@ -1,31 +1,58 @@ namespace HLabs.Containers; +/// +/// Base class for container image references, providing common properties. +/// public abstract record ImageRef { - protected ImageRef( Repository repository ) { + /// + /// Initializes a new instance of the class. + /// + /// The repository name. Cannot be null. + /// Thrown when repository is null. + private protected ImageRef( Repository repository ) { Repository = repository ?? throw new ArgumentNullException( nameof(repository) ); } + /// + /// 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 user name). + /// 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). + /// public Repository Repository { get; } + /// + /// Gets the tag that identifies a version or variant (e.g., latest, v1.0, alpine). + /// May be null for digest-only references. + /// public Tag? Tag { get; protected init; } + /// + /// Gets the content-addressable digest that uniquely identifies image content. + /// May be null for tag-only references. + /// public Digest? Digest { get; protected init; diff --git a/dotnet/Containers/Namespace.cs b/dotnet/Containers/Namespace.cs index c1045e4..7dc5297 100644 --- a/dotnet/Containers/Namespace.cs +++ b/dotnet/Containers/Namespace.cs @@ -2,6 +2,18 @@ namespace HLabs.Containers; +/// +/// Represents a namespace within a container registry (e.g., organization or user name). +/// Namespaces are used to organize repositories within a registry. +/// +/// +/// Examples of valid namespaces: +/// +/// var ns = new Namespace("library"); // DockerHub official images +/// var ns = new Namespace("myorg"); // Organization namespace +/// var ns = new Namespace("username"); // User namespace +/// +/// [SuppressMessage( "Naming", "CA1716:Identifiers should not match keywords", @@ -12,6 +24,11 @@ private string Name { get; } + /// + /// Initializes a new instance of the class. + /// + /// The namespace name. Cannot be null, empty, or contain forward slashes. + /// Thrown when name is null, empty, whitespace-only, contains leading/trailing whitespace, or contains a forward slash. public Namespace( string name ) { if ( string.IsNullOrWhiteSpace( name ) ) { throw new ArgumentException( "Namespace cannot be null or empty", nameof(name) ); @@ -31,10 +48,24 @@ public Namespace( string name ) { #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); } diff --git a/dotnet/Containers/PartialImageRef.cs b/dotnet/Containers/PartialImageRef.cs index c477fe6..25eb084 100644 --- a/dotnet/Containers/PartialImageRef.cs +++ b/dotnet/Containers/PartialImageRef.cs @@ -15,17 +15,6 @@ namespace HLabs.Containers; /// Tag: 2.0 ///
///
-[SuppressMessage( - "StyleCop.CSharp.DocumentationRules", - "SA1623:Property summary documentation should match accessors", - Justification = "Temp" -)] -[SuppressMessage( "Design", "CA1031:Do not catch general exception types", Justification = "Temp" )] -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] -[SuppressMessage( - "StyleCop.CSharp.MaintainabilityRules", - "SA1404:Code analysis suppression should have justification" )] public sealed partial record PartialImageRef : ImageRef { private PartialImageRef( Repository repository, @@ -36,7 +25,6 @@ private PartialImageRef( Namespace? @namespace = null, Digest? digest = null ) : base( repository ) { - ArgumentNullException.ThrowIfNull( repository ); // TODO test this Tag = tag; Registry = registry; Namespace = @namespace; @@ -167,7 +155,7 @@ public PartialImageRef With( Digest? digest ) => public override bool IsQualified => Registry != null && ( !Registry.NamespaceRequired || Namespace != null ) && ( Tag != null || Digest != null ); - public bool CanQualify => TryQualify( out _, out _ ); // TODO Bad impl + public bool CanQualify => TryQualify( out _, out _ ); /// /// Converts this reference to a canonical form. diff --git a/dotnet/Containers/PartialImageRefExtensions.cs b/dotnet/Containers/PartialImageRefExtensions.cs index 41216d3..c2ce343 100644 --- a/dotnet/Containers/PartialImageRefExtensions.cs +++ b/dotnet/Containers/PartialImageRefExtensions.cs @@ -1,37 +1,50 @@ namespace HLabs.Containers; +/// +/// Extension methods for that provide convenient qualification with different components. +/// public static class PartialImageRefExtensions { #pragma warning disable CA1034 extension( PartialImageRef imageRef ) { #pragma warning restore CA1034 /// - /// Returns a new instance with a different cosmetic tag. + /// Qualifies the image reference with a different tag, filling in defaults for missing components. /// + /// The tag to use. + /// A qualified image reference. public QualifiedImageRef Qualify( Tag? tag ) => imageRef.With( tag ).Qualify(); /// - /// Returns a new instance with a different registry. + /// Qualifies the image reference with a different registry, filling in defaults for missing components. /// + /// The registry to use. + /// A qualified image reference. public QualifiedImageRef Qualify( Registry? registry ) => imageRef.With( registry ).Qualify(); /// - /// Returns a new instance with a different registry. + /// Qualifies the image reference with a different registry and namespace, filling in defaults for missing components. /// + /// The registry to use. + /// The namespace to use. + /// A qualified image reference. public QualifiedImageRef Qualify( Registry registry, Namespace ns ) => imageRef.With( registry, ns ).Qualify(); /// - /// Returns a new instance with a different namespace. + /// Qualifies the image reference with a different namespace, filling in defaults for missing components. /// + /// The namespace to use. + /// A qualified image reference. public QualifiedImageRef Qualify( Namespace? ns ) => - // TODO remember registry/namespace requirement imageRef.With( ns ).Qualify(); /// - /// Returns a new instance with a different digest. + /// Qualifies the image reference with a different digest, filling in defaults for missing components. /// + /// The digest to use. + /// A qualified image reference. public QualifiedImageRef Qualify( Digest? digest ) => imageRef.With( digest ).Qualify(); } diff --git a/dotnet/Containers/QualificationMode.cs b/dotnet/Containers/QualificationMode.cs index f92d7fe..d3f1312 100644 --- a/dotnet/Containers/QualificationMode.cs +++ b/dotnet/Containers/QualificationMode.cs @@ -1,6 +1,16 @@ namespace HLabs.Containers; +/// +/// 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/Containers/QualifiedImageRef.cs b/dotnet/Containers/QualifiedImageRef.cs index f64040a..7a7d242 100644 --- a/dotnet/Containers/QualifiedImageRef.cs +++ b/dotnet/Containers/QualifiedImageRef.cs @@ -1,40 +1,19 @@ -using System.Diagnostics.CodeAnalysis; - namespace HLabs.Containers; // ----------------------------- -// Canonical image reference: fully qualified (registry, namespace, repository, tag or digest) +// Qualified image reference: fully qualified (registry, namespace, repository, tag or digest) // ----------------------------- -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1615:Element return value should be documented" )] -[SuppressMessage( "StyleCop.CSharp.DocumentationRules", "SA1611:Element parameters should be documented" )] -[SuppressMessage( - "StyleCop.CSharp.MaintainabilityRules", - "SA1404:Code analysis suppression should have justification" )] /// -/// Mutable image reference. -/// Can adress an image, but the underlying image may change. -/// (registry, optional- depends on registy namespace, repository, tag or digest) +/// 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 { public new Registry Registry { get; } - /* - public Namespace? Namespace { - get; - } // only required if the registry requires it - */ - -/* -public Tag? Tag { - get; -} // nullable if digest-only - -public Digest? Digest { - get; -} // nullable if tag-only -*/ internal QualifiedImageRef( Registry registry, Namespace? ns, Repository repository, Tag? tag, Digest? digest ) : base( repository ) { Registry = registry ?? throw new ArgumentNullException( nameof(registry) ); @@ -69,9 +48,17 @@ public CanonicalImageRef Canonicalize( Digest digest ) { return new CanonicalImageRef( Registry, Namespace, Repository, digest ); } + /// + /// Creates a pinned image reference using the current digest. + /// + /// Thrown when digest is not present. public CanonicalImageRef Canonicalize() { - // ! TODO: - return Canonicalize( Digest! ); + if ( Digest is null ) { + throw new InvalidOperationException( + "Cannot canonicalize without a digest. Use Canonicalize(digest) to provide one." ); + } + + return new CanonicalImageRef( Registry, Namespace, Repository, Digest ); } public override bool IsQualified => true; diff --git a/dotnet/Containers/Registry.Instances.cs b/dotnet/Containers/Registry.Instances.cs index 262af8e..426880f 100644 --- a/dotnet/Containers/Registry.Instances.cs +++ b/dotnet/Containers/Registry.Instances.cs @@ -1,9 +1,24 @@ namespace HLabs.Containers; public sealed partial record Registry { + /// + /// Docker Hub registry (docker.io). Requires a namespace. + /// public static readonly Registry DockerHub = new("docker.io", true); + + /// + /// Quay.io container registry. Requires a namespace. + /// public static readonly Registry Quay = new("quay.io", true); + + /// + /// GitHub Container Registry (ghcr.io). Requires a namespace. + /// public static readonly Registry GitHub = new("ghcr.io", true); + + /// + /// Local Docker registry (localhost:5000). Does not require a namespace. + /// public static readonly Registry Localhost = new("localhost:5000", false); /// diff --git a/dotnet/Containers/Registry.cs b/dotnet/Containers/Registry.cs index 0ff541a..a1878fe 100644 --- a/dotnet/Containers/Registry.cs +++ b/dotnet/Containers/Registry.cs @@ -1,5 +1,16 @@ namespace HLabs.Containers; +/// +/// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). +/// A registry is where container images are stored and distributed from. +/// +/// +/// +/// 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; @@ -9,6 +20,12 @@ 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) ); @@ -25,11 +42,25 @@ public Registry( string host, bool namespaceRequired = false ) { 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; } \ No newline at end of file diff --git a/dotnet/Containers/Repository.cs b/dotnet/Containers/Repository.cs index 4f8945c..d8f08dd 100644 --- a/dotnet/Containers/Repository.cs +++ b/dotnet/Containers/Repository.cs @@ -1,10 +1,26 @@ namespace HLabs.Containers; +/// +/// 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) ); @@ -24,11 +40,25 @@ public Repository( string name ) { #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/Containers/StringExtensions.cs b/dotnet/Containers/StringExtensions.cs index e94ed68..3c6baf6 100644 --- a/dotnet/Containers/StringExtensions.cs +++ b/dotnet/Containers/StringExtensions.cs @@ -1,12 +1,24 @@ namespace HLabs.Containers; +/// +/// 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 an unqualified container image reference. + /// 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 ); /* /// diff --git a/dotnet/Containers/Tag.Instances.cs b/dotnet/Containers/Tag.Instances.cs index 4561362..5a6b937 100644 --- a/dotnet/Containers/Tag.Instances.cs +++ b/dotnet/Containers/Tag.Instances.cs @@ -1,5 +1,8 @@ namespace HLabs.Containers; 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/Containers/Tag.cs b/dotnet/Containers/Tag.cs index 662690d..ae23435 100644 --- a/dotnet/Containers/Tag.cs +++ b/dotnet/Containers/Tag.cs @@ -2,11 +2,27 @@ namespace HLabs.Containers; +/// +/// 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) ); @@ -22,18 +38,44 @@ public Tag( string value ) { #pragma warning restore CA1308 } + /// + /// Implicitly converts a string to a . + /// + /// The tag value. public static implicit operator Tag( string tag ) => FromString( tag ); - public static implicit operator Tag( SemVersion v ) => FromSemVersion( v ); // TODO move to subtype? + /// + /// 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 ) { - // ! Null check is performed by constructor - return new(v?.WithoutMetadata().ToString()!); + 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 From a7a7bedde3e14ebead56fe08a11ff5eae6033b9a Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:31:19 +0100 Subject: [PATCH 07/36] cononicalizationmode + tests for all imagerefs --- .../CanonicalImageRefTests.cs | 329 ++++++++++++++++++ ...Tests.cs => PartialImageReferenceTests.cs} | 2 +- .../QualifiedImageRefTests.cs | 244 +++++++++++++ dotnet/Containers/CanonicalImageRef.cs | 6 + dotnet/Containers/CanonicalizationMode.cs | 19 + dotnet/Containers/PartialImageRef.cs | 38 +- dotnet/Containers/QualifiedImageRef.cs | 19 +- 7 files changed, 645 insertions(+), 12 deletions(-) create mode 100644 dotnet/Containers.Tests/CanonicalImageRefTests.cs rename dotnet/Containers.Tests/{ImageReferenceTests.cs => PartialImageReferenceTests.cs} (99%) create mode 100644 dotnet/Containers.Tests/QualifiedImageRefTests.cs create mode 100644 dotnet/Containers/CanonicalizationMode.cs diff --git a/dotnet/Containers.Tests/CanonicalImageRefTests.cs b/dotnet/Containers.Tests/CanonicalImageRefTests.cs new file mode 100644 index 0000000..0ecd2b4 --- /dev/null +++ b/dotnet/Containers.Tests/CanonicalImageRefTests.cs @@ -0,0 +1,329 @@ +namespace HLabs.Containers.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" ), + null, 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 ) ); + } + + // ----------------------- + // IsQualified + // ----------------------- + [Test] + public async Task IsQualifiedAlwaysTrue() { + var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + await Assert.That( canonical.IsQualified ).IsTrue(); + } + + // ----------------------- + // IsPinned + // ----------------------- + [Test] + public async Task IsPinnedAlwaysTrue() { + var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + await Assert.That( canonical.IsPinned ).IsTrue(); + } + + [Test] + public async Task IsPinnedTrueEvenWithTag() { + var canonical = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) + .Canonicalize(); + await Assert.That( canonical.IsPinned ).IsTrue(); + } + + // ----------------------- + // 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" ), + null, 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.WithTag( 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 WithTagReplacesExistingTag() { + var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Canonicalize(); + var updated = original.WithTag( 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.WithTag( Tag.Latest ); + + await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( updated.IsPinned ).IsTrue(); + } + + [Test] + public async Task WithTagDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + _ = original.WithTag( 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.On( 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.On( Registry.Localhost ); + + await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); + await Assert.That( updated.IsPinned ).IsTrue(); + } + + [Test] + public async Task OnDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + _ = original.On( 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.WithNamespace( 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.WithNamespace( 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.WithNamespace( 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.WithTag( Tag.Latest ); + var withRegistry = original.On( Registry.GitHub ); + var withNamespace = original.WithNamespace( 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 ) ); + } + + [Test] + public async Task AllVariantsAreImmutable() { + var ref1 = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); + var ref2 = ref1.WithTag( Tag.Latest ); + var ref3 = ref2.On( Registry.GitHub ); + + await Assert.That( ref1.IsPinned ).IsTrue(); + await Assert.That( ref2.IsPinned ).IsTrue(); + await Assert.That( ref3.IsPinned ).IsTrue(); + } + + // ----------------------- + // 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" ), + null, new Digest( ValidDigest ) ) + .Canonicalize(); + var b = new PartialImageRef( Registry.GitHub, new Namespace( "library" ), new Repository( "nginx" ), + null, 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.WithTag( 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/Containers.Tests/ImageReferenceTests.cs b/dotnet/Containers.Tests/PartialImageReferenceTests.cs similarity index 99% rename from dotnet/Containers.Tests/ImageReferenceTests.cs rename to dotnet/Containers.Tests/PartialImageReferenceTests.cs index 2dcb707..b264d10 100644 --- a/dotnet/Containers.Tests/ImageReferenceTests.cs +++ b/dotnet/Containers.Tests/PartialImageReferenceTests.cs @@ -2,7 +2,7 @@ namespace HLabs.Containers.Tests; -internal sealed class ImageReferenceTests { +internal sealed class PartialImageReferenceTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; // ----------------------- diff --git a/dotnet/Containers.Tests/QualifiedImageRefTests.cs b/dotnet/Containers.Tests/QualifiedImageRefTests.cs new file mode 100644 index 0000000..5f971a2 --- /dev/null +++ b/dotnet/Containers.Tests/QualifiedImageRefTests.cs @@ -0,0 +1,244 @@ +namespace HLabs.Containers.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" ) ); + } + + // ----------------------- + // IsQualified + // ----------------------- + [Test] + public async Task IsQualifiedAlwaysTrue() { + var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + await Assert.That( image.IsQualified ).IsTrue(); + } + + // ----------------------- + // IsPinned + // ----------------------- + [Test] + public async Task IsPinnedFalseWhenOnlyTag() { + var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + await Assert.That( image.IsPinned ).IsFalse(); + } + + [Test] + public async Task IsPinnedTrueWhenDigestPresent() { + var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Qualify(); + await Assert.That( image.IsPinned ).IsTrue(); + } + + [Test] + public async Task IsPinnedTrueWhenBothTagAndDigest() { + var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Qualify(); + await Assert.That( image.IsPinned ).IsTrue(); + } + + // ----------------------- + // 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.WithTag( 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.WithTag( 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.WithTag( new Tag( "alpine" ) ); + + await Assert.That( original.Tag ).IsEqualTo( Tag.Latest ); + } + + // ----------------------- + // On (change registry) + // ----------------------- + [Test] + public async Task OnChangesRegistry() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var updated = original.On( 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 OnPreservesOtherProperties() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + var updated = original.On( Registry.Localhost ); + + await Assert.That( updated.Repository ).IsEqualTo( new Repository( "nginx" ) ); + await Assert.That( updated.Tag ).IsEqualTo( Tag.Latest ); + } + + [Test] + public async Task OnDoesNotMutateOriginal() { + var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); + _ = original.On( 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 ) ); + await Assert.That( canonical.IsQualified ).IsTrue(); + } + + [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/Containers/CanonicalImageRef.cs b/dotnet/Containers/CanonicalImageRef.cs index 782ea71..3157e66 100644 --- a/dotnet/Containers/CanonicalImageRef.cs +++ b/dotnet/Containers/CanonicalImageRef.cs @@ -46,6 +46,12 @@ public CanonicalImageRef WithNamespace( Namespace ns ) => public override bool IsQualified => true; + /// + /// Gets a value indicating whether this reference is pinned by a digest. + /// Always true for canonical references as they always have a digest. + /// + public override bool IsPinned => true; + public override string ToString() { var nsPart = Namespace is not null ? $"{Namespace}/" : string.Empty; return Tag is not null diff --git a/dotnet/Containers/CanonicalizationMode.cs b/dotnet/Containers/CanonicalizationMode.cs new file mode 100644 index 0000000..89be0ef --- /dev/null +++ b/dotnet/Containers/CanonicalizationMode.cs @@ -0,0 +1,19 @@ +namespace HLabs.Containers; + +/// +/// 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 +} + diff --git a/dotnet/Containers/PartialImageRef.cs b/dotnet/Containers/PartialImageRef.cs index 25eb084..769513d 100644 --- a/dotnet/Containers/PartialImageRef.cs +++ b/dotnet/Containers/PartialImageRef.cs @@ -74,6 +74,7 @@ public PartialImageRef( Registry registry, Repository repository ) // Overload clashes with Namespace + Repository + Tag when using string literals (implicit conversions), so not included for now. // The more common use case is probably the other one. + // TODO decide public PartialImageRef( Registry registry, Repository repository, Tag tag ) : this( repository, tag, registry, null, null ) { } @@ -90,6 +91,10 @@ public PartialImageRef( Registry registry, Namespace ns, Repository repository, : this( repository, tag, registry, ns, null ) { } + public PartialImageRef( Registry registry, Namespace ns, Repository repository, Digest digest ) + : this( repository, null, registry, ns, digest ) { + } + public PartialImageRef( Registry registry, Repository repository, Tag tag, Digest digest ) : this( repository, tag, registry, null, digest ) { } @@ -163,7 +168,7 @@ public PartialImageRef With( Digest? digest ) => /// Either a tag or digest must be present. /// /// The behavior when the reference is not in a canonical form. - /// Throws InvalidOperationException if the reference is not in a canonical form. + /// Throws InvalidOperationException if the reference is not in a qualified form. /// A canonical image reference. public QualifiedImageRef Qualify( QualificationMode mode = QualificationMode.DefaultFilling ) { // Defaults @@ -179,11 +184,34 @@ public QualifiedImageRef Qualify( QualificationMode mode = QualificationMode.Def return new QualifiedImageRef( registry, ns, repo, tag, Digest ); } - public CanonicalImageRef Canonicalize( Digest digest, QualificationMode mode = QualificationMode.DefaultFilling ) { - return With( digest ).Qualify( mode ).Canonicalize(); + + /// + /// Canonicalizes the image reference using the current digest. + /// + /// Whether to maintain or exclude the tag. + /// 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 ); } - public CanonicalImageRef Canonicalize( QualificationMode mode = QualificationMode.DefaultFilling ) { - return Qualify( mode ).Canonicalize(); + + /// + /// Canonicalizes the image reference with a specific digest. + /// + /// The digest to use. + /// Whether to maintain or exclude the tag. + /// 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 ); } } \ No newline at end of file diff --git a/dotnet/Containers/QualifiedImageRef.cs b/dotnet/Containers/QualifiedImageRef.cs index 7a7d242..b21789b 100644 --- a/dotnet/Containers/QualifiedImageRef.cs +++ b/dotnet/Containers/QualifiedImageRef.cs @@ -40,25 +40,32 @@ public QualifiedImageRef On( Registry registry, Namespace? ns = null ) => new(registry, ns ?? Namespace, Repository, Tag, Digest); /// - /// Creates a pinned image reference. + /// Creates a digest-pinned image reference. /// - public CanonicalImageRef Canonicalize( Digest digest ) { + /// The digest to pin the reference with. + /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// A canonical (immutable) image reference. + /// Thrown when digest is null. + public CanonicalImageRef Canonicalize( Digest digest, CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { ArgumentNullException.ThrowIfNull( digest ); - return new CanonicalImageRef( Registry, Namespace, Repository, digest ); + var tag = mode == CanonicalizationMode.MaintainTag ? Tag : null; + return new CanonicalImageRef( Registry, Namespace, Repository, digest, tag ); } /// - /// Creates a pinned image reference using the current digest. + /// Creates a digest-pinned image reference using the current digest. /// + /// Specifies whether to maintain or exclude the tag in the canonical reference. /// Thrown when digest is not present. - public CanonicalImageRef Canonicalize() { + 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." ); } - return new CanonicalImageRef( Registry, Namespace, Repository, Digest ); + var tag = mode == CanonicalizationMode.MaintainTag ? Tag : null; + return new CanonicalImageRef( Registry, Namespace, Repository, Digest, tag ); } public override bool IsQualified => true; From 42c76a67ab269d730032e2989039fba78ba4f1d4 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:06:08 +0100 Subject: [PATCH 08/36] refactor --- dotnet/Containers/{ => Components}/Digest.cs | 0 dotnet/Containers/{ => Components}/Namespace.Instances.cs | 0 dotnet/Containers/{ => Components}/Namespace.cs | 0 dotnet/Containers/{ => Components}/Registry.Instances.cs | 0 dotnet/Containers/{ => Components}/Registry.cs | 0 dotnet/Containers/{ => Components}/Repository.cs | 0 dotnet/Containers/{ => Components}/Tag.Instances.cs | 0 dotnet/Containers/{ => Components}/Tag.cs | 0 dotnet/Containers/{ => Parsing}/StringExtensions.cs | 0 dotnet/Containers/PartialImageRef.cs | 2 -- 10 files changed, 2 deletions(-) rename dotnet/Containers/{ => Components}/Digest.cs (100%) rename dotnet/Containers/{ => Components}/Namespace.Instances.cs (100%) rename dotnet/Containers/{ => Components}/Namespace.cs (100%) rename dotnet/Containers/{ => Components}/Registry.Instances.cs (100%) rename dotnet/Containers/{ => Components}/Registry.cs (100%) rename dotnet/Containers/{ => Components}/Repository.cs (100%) rename dotnet/Containers/{ => Components}/Tag.Instances.cs (100%) rename dotnet/Containers/{ => Components}/Tag.cs (100%) rename dotnet/Containers/{ => Parsing}/StringExtensions.cs (100%) diff --git a/dotnet/Containers/Digest.cs b/dotnet/Containers/Components/Digest.cs similarity index 100% rename from dotnet/Containers/Digest.cs rename to dotnet/Containers/Components/Digest.cs diff --git a/dotnet/Containers/Namespace.Instances.cs b/dotnet/Containers/Components/Namespace.Instances.cs similarity index 100% rename from dotnet/Containers/Namespace.Instances.cs rename to dotnet/Containers/Components/Namespace.Instances.cs diff --git a/dotnet/Containers/Namespace.cs b/dotnet/Containers/Components/Namespace.cs similarity index 100% rename from dotnet/Containers/Namespace.cs rename to dotnet/Containers/Components/Namespace.cs diff --git a/dotnet/Containers/Registry.Instances.cs b/dotnet/Containers/Components/Registry.Instances.cs similarity index 100% rename from dotnet/Containers/Registry.Instances.cs rename to dotnet/Containers/Components/Registry.Instances.cs diff --git a/dotnet/Containers/Registry.cs b/dotnet/Containers/Components/Registry.cs similarity index 100% rename from dotnet/Containers/Registry.cs rename to dotnet/Containers/Components/Registry.cs diff --git a/dotnet/Containers/Repository.cs b/dotnet/Containers/Components/Repository.cs similarity index 100% rename from dotnet/Containers/Repository.cs rename to dotnet/Containers/Components/Repository.cs diff --git a/dotnet/Containers/Tag.Instances.cs b/dotnet/Containers/Components/Tag.Instances.cs similarity index 100% rename from dotnet/Containers/Tag.Instances.cs rename to dotnet/Containers/Components/Tag.Instances.cs diff --git a/dotnet/Containers/Tag.cs b/dotnet/Containers/Components/Tag.cs similarity index 100% rename from dotnet/Containers/Tag.cs rename to dotnet/Containers/Components/Tag.cs diff --git a/dotnet/Containers/StringExtensions.cs b/dotnet/Containers/Parsing/StringExtensions.cs similarity index 100% rename from dotnet/Containers/StringExtensions.cs rename to dotnet/Containers/Parsing/StringExtensions.cs diff --git a/dotnet/Containers/PartialImageRef.cs b/dotnet/Containers/PartialImageRef.cs index 769513d..cfc1291 100644 --- a/dotnet/Containers/PartialImageRef.cs +++ b/dotnet/Containers/PartialImageRef.cs @@ -18,9 +18,7 @@ namespace HLabs.Containers; 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 From dc524c1565a1bc36f52cd3d0575f0dddfce1af27 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:08:37 +0100 Subject: [PATCH 09/36] refactor --- .../DockerLoginSettingsExtensions.cs | 3 ++- .../DockerLogoutSettingsExtensions.cs | 3 ++- dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs | 1 + dotnet/Containers.Tests/CanonicalImageRefTests.cs | 2 ++ dotnet/Containers.Tests/DigestTests.cs | 2 ++ dotnet/Containers.Tests/NamespaceTests.cs | 2 ++ dotnet/Containers.Tests/PartialImageReferenceTests.cs | 3 ++- dotnet/Containers.Tests/QualifiedImageRefTests.cs | 2 ++ dotnet/Containers.Tests/RegistryTests.cs | 2 ++ dotnet/Containers.Tests/RepositoryTests.cs | 2 ++ dotnet/Containers.Tests/TagTests.cs | 1 + dotnet/Containers/CanonicalImageRef.cs | 2 ++ dotnet/Containers/Components/Digest.cs | 2 +- dotnet/Containers/Components/Namespace.Instances.cs | 2 +- dotnet/Containers/Components/Namespace.cs | 2 +- dotnet/Containers/Components/Registry.Instances.cs | 2 +- dotnet/Containers/Components/Registry.cs | 2 +- dotnet/Containers/Components/Repository.cs | 2 +- dotnet/Containers/Components/Tag.Instances.cs | 2 +- dotnet/Containers/Components/Tag.cs | 2 +- dotnet/Containers/ImageRef.cs | 2 ++ dotnet/Containers/PartialImageRef.Parsing.cs | 1 + dotnet/Containers/PartialImageRef.cs | 1 + dotnet/Containers/PartialImageRefExtensions.cs | 2 ++ dotnet/Containers/QualifiedImageRef.cs | 2 ++ 25 files changed, 38 insertions(+), 11 deletions(-) diff --git a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs index d18239a..6070bac 100644 --- a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs +++ b/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -1,4 +1,5 @@ -using Nuke.Common.Tools.Docker; +using HLabs.Containers.Components; +using Nuke.Common.Tools.Docker; namespace HLabs.Containers.Extensions.Nuke; diff --git a/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs index 9098581..2acf10e 100644 --- a/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs +++ b/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -1,4 +1,5 @@ -using Nuke.Common.Tools.Docker; +using HLabs.Containers.Components; +using Nuke.Common.Tools.Docker; namespace HLabs.Containers.Extensions.Nuke; diff --git a/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs index 6503349..3530314 100644 --- a/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs +++ b/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs @@ -1,3 +1,4 @@ +using HLabs.Containers.Components; using Nuke.Common.Tooling; using Nuke.Common.Tools.Docker; diff --git a/dotnet/Containers.Tests/CanonicalImageRefTests.cs b/dotnet/Containers.Tests/CanonicalImageRefTests.cs index 0ecd2b4..e254770 100644 --- a/dotnet/Containers.Tests/CanonicalImageRefTests.cs +++ b/dotnet/Containers.Tests/CanonicalImageRefTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class CanonicalImageRefTests { diff --git a/dotnet/Containers.Tests/DigestTests.cs b/dotnet/Containers.Tests/DigestTests.cs index d2c3821..fce6949 100644 --- a/dotnet/Containers.Tests/DigestTests.cs +++ b/dotnet/Containers.Tests/DigestTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class DigestTests { diff --git a/dotnet/Containers.Tests/NamespaceTests.cs b/dotnet/Containers.Tests/NamespaceTests.cs index 7eab864..d25d655 100644 --- a/dotnet/Containers.Tests/NamespaceTests.cs +++ b/dotnet/Containers.Tests/NamespaceTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class NamespaceTests { diff --git a/dotnet/Containers.Tests/PartialImageReferenceTests.cs b/dotnet/Containers.Tests/PartialImageReferenceTests.cs index b264d10..0beeed6 100644 --- a/dotnet/Containers.Tests/PartialImageReferenceTests.cs +++ b/dotnet/Containers.Tests/PartialImageReferenceTests.cs @@ -1,4 +1,5 @@ -using Semver; +using HLabs.Containers.Components; +using Semver; namespace HLabs.Containers.Tests; diff --git a/dotnet/Containers.Tests/QualifiedImageRefTests.cs b/dotnet/Containers.Tests/QualifiedImageRefTests.cs index 5f971a2..5088320 100644 --- a/dotnet/Containers.Tests/QualifiedImageRefTests.cs +++ b/dotnet/Containers.Tests/QualifiedImageRefTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class QualifiedImageRefTests { diff --git a/dotnet/Containers.Tests/RegistryTests.cs b/dotnet/Containers.Tests/RegistryTests.cs index 35d2f1f..8c8cf69 100644 --- a/dotnet/Containers.Tests/RegistryTests.cs +++ b/dotnet/Containers.Tests/RegistryTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class RegistryTests { diff --git a/dotnet/Containers.Tests/RepositoryTests.cs b/dotnet/Containers.Tests/RepositoryTests.cs index ad86068..87acdc7 100644 --- a/dotnet/Containers.Tests/RepositoryTests.cs +++ b/dotnet/Containers.Tests/RepositoryTests.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers.Tests; internal sealed class RepositoryTests { diff --git a/dotnet/Containers.Tests/TagTests.cs b/dotnet/Containers.Tests/TagTests.cs index 9c33717..3b2fae7 100644 --- a/dotnet/Containers.Tests/TagTests.cs +++ b/dotnet/Containers.Tests/TagTests.cs @@ -1,3 +1,4 @@ +using HLabs.Containers.Components; using Semver; namespace HLabs.Containers.Tests; diff --git a/dotnet/Containers/CanonicalImageRef.cs b/dotnet/Containers/CanonicalImageRef.cs index 3157e66..08aad5b 100644 --- a/dotnet/Containers/CanonicalImageRef.cs +++ b/dotnet/Containers/CanonicalImageRef.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers; /// diff --git a/dotnet/Containers/Components/Digest.cs b/dotnet/Containers/Components/Digest.cs index c15a7fe..65e353b 100644 --- a/dotnet/Containers/Components/Digest.cs +++ b/dotnet/Containers/Components/Digest.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace HLabs.Containers; +namespace HLabs.Containers.Components; /// /// Represents a digest for a container image e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4. diff --git a/dotnet/Containers/Components/Namespace.Instances.cs b/dotnet/Containers/Components/Namespace.Instances.cs index 27a2115..6932808 100644 --- a/dotnet/Containers/Components/Namespace.Instances.cs +++ b/dotnet/Containers/Components/Namespace.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.Components; public sealed partial record Namespace { /// diff --git a/dotnet/Containers/Components/Namespace.cs b/dotnet/Containers/Components/Namespace.cs index 7dc5297..131582c 100644 --- a/dotnet/Containers/Components/Namespace.cs +++ b/dotnet/Containers/Components/Namespace.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace HLabs.Containers; +namespace HLabs.Containers.Components; /// /// Represents a namespace within a container registry (e.g., organization or user name). diff --git a/dotnet/Containers/Components/Registry.Instances.cs b/dotnet/Containers/Components/Registry.Instances.cs index 426880f..91784c1 100644 --- a/dotnet/Containers/Components/Registry.Instances.cs +++ b/dotnet/Containers/Components/Registry.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.Components; public sealed partial record Registry { /// diff --git a/dotnet/Containers/Components/Registry.cs b/dotnet/Containers/Components/Registry.cs index a1878fe..cb4f752 100644 --- a/dotnet/Containers/Components/Registry.cs +++ b/dotnet/Containers/Components/Registry.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.Components; /// /// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). diff --git a/dotnet/Containers/Components/Repository.cs b/dotnet/Containers/Components/Repository.cs index d8f08dd..eec9528 100644 --- a/dotnet/Containers/Components/Repository.cs +++ b/dotnet/Containers/Components/Repository.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.Components; /// /// Represents a repository name within a container registry namespace. diff --git a/dotnet/Containers/Components/Tag.Instances.cs b/dotnet/Containers/Components/Tag.Instances.cs index 5a6b937..1a87112 100644 --- a/dotnet/Containers/Components/Tag.Instances.cs +++ b/dotnet/Containers/Components/Tag.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.Components; public sealed partial record Tag { /// diff --git a/dotnet/Containers/Components/Tag.cs b/dotnet/Containers/Components/Tag.cs index ae23435..6a29f52 100644 --- a/dotnet/Containers/Components/Tag.cs +++ b/dotnet/Containers/Components/Tag.cs @@ -1,6 +1,6 @@ using Semver; -namespace HLabs.Containers; +namespace HLabs.Containers.Components; /// /// Represents a tag for a container image (e.g., "latest", "1.0", "v2.3.1"). diff --git a/dotnet/Containers/ImageRef.cs b/dotnet/Containers/ImageRef.cs index fd1ac5f..a58c00d 100644 --- a/dotnet/Containers/ImageRef.cs +++ b/dotnet/Containers/ImageRef.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers; /// diff --git a/dotnet/Containers/PartialImageRef.Parsing.cs b/dotnet/Containers/PartialImageRef.Parsing.cs index 9ab5576..5e12070 100644 --- a/dotnet/Containers/PartialImageRef.Parsing.cs +++ b/dotnet/Containers/PartialImageRef.Parsing.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using HLabs.Containers.Components; namespace HLabs.Containers; diff --git a/dotnet/Containers/PartialImageRef.cs b/dotnet/Containers/PartialImageRef.cs index cfc1291..28c4e32 100644 --- a/dotnet/Containers/PartialImageRef.cs +++ b/dotnet/Containers/PartialImageRef.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using HLabs.Containers.Components; namespace HLabs.Containers; diff --git a/dotnet/Containers/PartialImageRefExtensions.cs b/dotnet/Containers/PartialImageRefExtensions.cs index c2ce343..942897b 100644 --- a/dotnet/Containers/PartialImageRefExtensions.cs +++ b/dotnet/Containers/PartialImageRefExtensions.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers; /// diff --git a/dotnet/Containers/QualifiedImageRef.cs b/dotnet/Containers/QualifiedImageRef.cs index b21789b..25258e4 100644 --- a/dotnet/Containers/QualifiedImageRef.cs +++ b/dotnet/Containers/QualifiedImageRef.cs @@ -1,3 +1,5 @@ +using HLabs.Containers.Components; + namespace HLabs.Containers; // ----------------------------- From bf09a6a650e75749df8d31f94c8a609580d3ee47 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:11:40 +0100 Subject: [PATCH 10/36] refactor --- HLabs.slnx | 6 +++--- dotnet/Containers.Extensions.Nuke/README.md | 3 --- .../Containers.ImageRefs.Extensions.Nuke.csproj} | 2 +- .../DockerLoginSettingsExtensions.cs | 0 .../DockerLogoutSettingsExtensions.cs | 0 .../DockerPushSettingsExtensions.cs | 0 .../DockerTagSettingsExtensions.cs | 0 .../ExtensionsMore.cs | 0 dotnet/Containers.ImageRefs.Extensions.Nuke/README.md | 3 +++ .../CanonicalImageRefTests.cs | 0 .../Containers.ImageRefs.Tests.csproj} | 2 +- .../DigestTests.cs | 0 .../NamespaceTests.cs | 0 .../PartialImageReferenceTests.cs | 0 .../QualifiedImageRefTests.cs | 0 .../RegistryTests.cs | 0 .../RepositoryTests.cs | 0 .../TagTests.cs | 0 dotnet/{Containers => Containers.ImageRefs}/AGENTS.md | 0 .../CanonicalImageRef.cs | 0 .../CanonicalizationMode.cs | 0 .../Components/Digest.cs | 0 .../Components/Namespace.Instances.cs | 0 .../Components/Namespace.cs | 0 .../Components/Registry.Instances.cs | 0 .../Components/Registry.cs | 0 .../Components/Repository.cs | 0 .../Components/Tag.Instances.cs | 0 .../{Containers => Containers.ImageRefs}/Components/Tag.cs | 0 .../Containers.ImageRefs.csproj} | 0 dotnet/{Containers => Containers.ImageRefs}/ImageId.cs | 0 dotnet/{Containers => Containers.ImageRefs}/ImageRef.cs | 0 .../Parsing/StringExtensions.cs | 0 .../PartialImageRef.Parsing.cs | 0 .../{Containers => Containers.ImageRefs}/PartialImageRef.cs | 0 .../PartialImageRefExtensions.cs | 0 .../QualificationMode.cs | 0 .../QualifiedImageRef.cs | 0 dotnet/{Containers => Containers.ImageRefs}/README.md | 4 ++-- 39 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 dotnet/Containers.Extensions.Nuke/README.md rename dotnet/{Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj => Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj} (84%) rename dotnet/{Containers.Extensions.Nuke => Containers.ImageRefs.Extensions.Nuke}/DockerLoginSettingsExtensions.cs (100%) rename dotnet/{Containers.Extensions.Nuke => Containers.ImageRefs.Extensions.Nuke}/DockerLogoutSettingsExtensions.cs (100%) rename dotnet/{Containers.Extensions.Nuke => Containers.ImageRefs.Extensions.Nuke}/DockerPushSettingsExtensions.cs (100%) rename dotnet/{Containers.Extensions.Nuke => Containers.ImageRefs.Extensions.Nuke}/DockerTagSettingsExtensions.cs (100%) rename dotnet/{Containers.Extensions.Nuke => Containers.ImageRefs.Extensions.Nuke}/ExtensionsMore.cs (100%) create mode 100644 dotnet/Containers.ImageRefs.Extensions.Nuke/README.md rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/CanonicalImageRefTests.cs (100%) rename dotnet/{Containers.Tests/Containers.Tests.csproj => Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj} (63%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/DigestTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/NamespaceTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/PartialImageReferenceTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/QualifiedImageRefTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/RegistryTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/RepositoryTests.cs (100%) rename dotnet/{Containers.Tests => Containers.ImageRefs.Tests}/TagTests.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/AGENTS.md (100%) rename dotnet/{Containers => Containers.ImageRefs}/CanonicalImageRef.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/CanonicalizationMode.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Digest.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Namespace.Instances.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Namespace.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Registry.Instances.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Registry.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Repository.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Tag.Instances.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Components/Tag.cs (100%) rename dotnet/{Containers/Containers.csproj => Containers.ImageRefs/Containers.ImageRefs.csproj} (100%) rename dotnet/{Containers => Containers.ImageRefs}/ImageId.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/ImageRef.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/Parsing/StringExtensions.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/PartialImageRef.Parsing.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/PartialImageRef.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/PartialImageRefExtensions.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/QualificationMode.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/QualifiedImageRef.cs (100%) rename dotnet/{Containers => Containers.ImageRefs}/README.md (98%) diff --git a/HLabs.slnx b/HLabs.slnx index eb62065..531867a 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -15,7 +15,7 @@ - - - + + + diff --git a/dotnet/Containers.Extensions.Nuke/README.md b/dotnet/Containers.Extensions.Nuke/README.md deleted file mode 100644 index d315af7..0000000 --- a/dotnet/Containers.Extensions.Nuke/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# HLabs.Containers.Extensions.Nuke - -This repository contains NUKE extensions for HLabs.Containers. \ No newline at end of file diff --git a/dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj b/dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj similarity index 84% rename from dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj rename to dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj index 768a55c..e4841f6 100644 --- a/dotnet/Containers.Extensions.Nuke/Containers.Extensions.Nuke.csproj +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs similarity index 100% rename from dotnet/Containers.Extensions.Nuke/DockerLoginSettingsExtensions.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs diff --git a/dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs similarity index 100% rename from dotnet/Containers.Extensions.Nuke/DockerLogoutSettingsExtensions.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs diff --git a/dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs similarity index 100% rename from dotnet/Containers.Extensions.Nuke/DockerPushSettingsExtensions.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs diff --git a/dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs similarity index 100% rename from dotnet/Containers.Extensions.Nuke/DockerTagSettingsExtensions.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs diff --git a/dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs similarity index 100% rename from dotnet/Containers.Extensions.Nuke/ExtensionsMore.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/README.md b/dotnet/Containers.ImageRefs.Extensions.Nuke/README.md new file mode 100644 index 0000000..a44489e --- /dev/null +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/README.md @@ -0,0 +1,3 @@ +# HLabs.Containers.ImageRefs.Extensions.Nuke + +This repository contains NUKE extensions for HLabs.Containers.ImageRefs. \ No newline at end of file diff --git a/dotnet/Containers.Tests/CanonicalImageRefTests.cs b/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs similarity index 100% rename from dotnet/Containers.Tests/CanonicalImageRefTests.cs rename to dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs diff --git a/dotnet/Containers.Tests/Containers.Tests.csproj b/dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj similarity index 63% rename from dotnet/Containers.Tests/Containers.Tests.csproj rename to dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj index ffb9963..1af31c2 100644 --- a/dotnet/Containers.Tests/Containers.Tests.csproj +++ b/dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/dotnet/Containers.Tests/DigestTests.cs b/dotnet/Containers.ImageRefs.Tests/DigestTests.cs similarity index 100% rename from dotnet/Containers.Tests/DigestTests.cs rename to dotnet/Containers.ImageRefs.Tests/DigestTests.cs diff --git a/dotnet/Containers.Tests/NamespaceTests.cs b/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs similarity index 100% rename from dotnet/Containers.Tests/NamespaceTests.cs rename to dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs diff --git a/dotnet/Containers.Tests/PartialImageReferenceTests.cs b/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs similarity index 100% rename from dotnet/Containers.Tests/PartialImageReferenceTests.cs rename to dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs diff --git a/dotnet/Containers.Tests/QualifiedImageRefTests.cs b/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs similarity index 100% rename from dotnet/Containers.Tests/QualifiedImageRefTests.cs rename to dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs diff --git a/dotnet/Containers.Tests/RegistryTests.cs b/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs similarity index 100% rename from dotnet/Containers.Tests/RegistryTests.cs rename to dotnet/Containers.ImageRefs.Tests/RegistryTests.cs diff --git a/dotnet/Containers.Tests/RepositoryTests.cs b/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs similarity index 100% rename from dotnet/Containers.Tests/RepositoryTests.cs rename to dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs diff --git a/dotnet/Containers.Tests/TagTests.cs b/dotnet/Containers.ImageRefs.Tests/TagTests.cs similarity index 100% rename from dotnet/Containers.Tests/TagTests.cs rename to dotnet/Containers.ImageRefs.Tests/TagTests.cs diff --git a/dotnet/Containers/AGENTS.md b/dotnet/Containers.ImageRefs/AGENTS.md similarity index 100% rename from dotnet/Containers/AGENTS.md rename to dotnet/Containers.ImageRefs/AGENTS.md diff --git a/dotnet/Containers/CanonicalImageRef.cs b/dotnet/Containers.ImageRefs/CanonicalImageRef.cs similarity index 100% rename from dotnet/Containers/CanonicalImageRef.cs rename to dotnet/Containers.ImageRefs/CanonicalImageRef.cs diff --git a/dotnet/Containers/CanonicalizationMode.cs b/dotnet/Containers.ImageRefs/CanonicalizationMode.cs similarity index 100% rename from dotnet/Containers/CanonicalizationMode.cs rename to dotnet/Containers.ImageRefs/CanonicalizationMode.cs diff --git a/dotnet/Containers/Components/Digest.cs b/dotnet/Containers.ImageRefs/Components/Digest.cs similarity index 100% rename from dotnet/Containers/Components/Digest.cs rename to dotnet/Containers.ImageRefs/Components/Digest.cs diff --git a/dotnet/Containers/Components/Namespace.Instances.cs b/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs similarity index 100% rename from dotnet/Containers/Components/Namespace.Instances.cs rename to dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs diff --git a/dotnet/Containers/Components/Namespace.cs b/dotnet/Containers.ImageRefs/Components/Namespace.cs similarity index 100% rename from dotnet/Containers/Components/Namespace.cs rename to dotnet/Containers.ImageRefs/Components/Namespace.cs diff --git a/dotnet/Containers/Components/Registry.Instances.cs b/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs similarity index 100% rename from dotnet/Containers/Components/Registry.Instances.cs rename to dotnet/Containers.ImageRefs/Components/Registry.Instances.cs diff --git a/dotnet/Containers/Components/Registry.cs b/dotnet/Containers.ImageRefs/Components/Registry.cs similarity index 100% rename from dotnet/Containers/Components/Registry.cs rename to dotnet/Containers.ImageRefs/Components/Registry.cs diff --git a/dotnet/Containers/Components/Repository.cs b/dotnet/Containers.ImageRefs/Components/Repository.cs similarity index 100% rename from dotnet/Containers/Components/Repository.cs rename to dotnet/Containers.ImageRefs/Components/Repository.cs diff --git a/dotnet/Containers/Components/Tag.Instances.cs b/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs similarity index 100% rename from dotnet/Containers/Components/Tag.Instances.cs rename to dotnet/Containers.ImageRefs/Components/Tag.Instances.cs diff --git a/dotnet/Containers/Components/Tag.cs b/dotnet/Containers.ImageRefs/Components/Tag.cs similarity index 100% rename from dotnet/Containers/Components/Tag.cs rename to dotnet/Containers.ImageRefs/Components/Tag.cs diff --git a/dotnet/Containers/Containers.csproj b/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj similarity index 100% rename from dotnet/Containers/Containers.csproj rename to dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj diff --git a/dotnet/Containers/ImageId.cs b/dotnet/Containers.ImageRefs/ImageId.cs similarity index 100% rename from dotnet/Containers/ImageId.cs rename to dotnet/Containers.ImageRefs/ImageId.cs diff --git a/dotnet/Containers/ImageRef.cs b/dotnet/Containers.ImageRefs/ImageRef.cs similarity index 100% rename from dotnet/Containers/ImageRef.cs rename to dotnet/Containers.ImageRefs/ImageRef.cs diff --git a/dotnet/Containers/Parsing/StringExtensions.cs b/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs similarity index 100% rename from dotnet/Containers/Parsing/StringExtensions.cs rename to dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs diff --git a/dotnet/Containers/PartialImageRef.Parsing.cs b/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs similarity index 100% rename from dotnet/Containers/PartialImageRef.Parsing.cs rename to dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs diff --git a/dotnet/Containers/PartialImageRef.cs b/dotnet/Containers.ImageRefs/PartialImageRef.cs similarity index 100% rename from dotnet/Containers/PartialImageRef.cs rename to dotnet/Containers.ImageRefs/PartialImageRef.cs diff --git a/dotnet/Containers/PartialImageRefExtensions.cs b/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs similarity index 100% rename from dotnet/Containers/PartialImageRefExtensions.cs rename to dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs diff --git a/dotnet/Containers/QualificationMode.cs b/dotnet/Containers.ImageRefs/QualificationMode.cs similarity index 100% rename from dotnet/Containers/QualificationMode.cs rename to dotnet/Containers.ImageRefs/QualificationMode.cs diff --git a/dotnet/Containers/QualifiedImageRef.cs b/dotnet/Containers.ImageRefs/QualifiedImageRef.cs similarity index 100% rename from dotnet/Containers/QualifiedImageRef.cs rename to dotnet/Containers.ImageRefs/QualifiedImageRef.cs diff --git a/dotnet/Containers/README.md b/dotnet/Containers.ImageRefs/README.md similarity index 98% rename from dotnet/Containers/README.md rename to dotnet/Containers.ImageRefs/README.md index d3dfc3b..3c0f2f6 100644 --- a/dotnet/Containers/README.md +++ b/dotnet/Containers.ImageRefs/README.md @@ -1,4 +1,4 @@ -# HLabs.Containers +# HLabs.Containers.ImageRefs Strongly-typed, validated container image references for .NET. @@ -11,7 +11,7 @@ Build, parse, and manipulate Docker/OCI image references (like `docker.io/librar Install via NuGet: ```bash -dotnet add package HLabs.Containers +dotnet add package HLabs.Containers.ImageRefs ``` ## Getting Started From 32788be8400708396ae30862ba8a538557210aeb Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:14:20 +0100 Subject: [PATCH 11/36] f --- dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj b/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj index 7bf9d8f..34712e0 100644 --- a/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj +++ b/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj @@ -2,7 +2,7 @@ Describe, validate, and manipulate container image references - Docker container containers + Docker Podman container containers image true From 99e8b7206e4f2c2b17e9cedc9f06ac376d77ef46 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:15:42 +0100 Subject: [PATCH 12/36] refactor --- .../DockerLoginSettingsExtensions.cs | 4 ++-- .../DockerLogoutSettingsExtensions.cs | 4 ++-- .../DockerPushSettingsExtensions.cs | 2 +- .../DockerTagSettingsExtensions.cs | 2 +- .../Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/DigestTests.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs | 4 ++-- .../Containers.ImageRefs.Tests/PartialImageReferenceTests.cs | 5 +++-- dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/RegistryTests.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs | 4 ++-- dotnet/Containers.ImageRefs.Tests/TagTests.cs | 4 ++-- dotnet/Containers.ImageRefs/CanonicalImageRef.cs | 4 ++-- dotnet/Containers.ImageRefs/CanonicalizationMode.cs | 2 +- dotnet/Containers.ImageRefs/Components/Digest.cs | 2 +- .../Containers.ImageRefs/Components/Namespace.Instances.cs | 2 +- dotnet/Containers.ImageRefs/Components/Namespace.cs | 2 +- dotnet/Containers.ImageRefs/Components/Registry.Instances.cs | 2 +- dotnet/Containers.ImageRefs/Components/Registry.cs | 2 +- dotnet/Containers.ImageRefs/Components/Repository.cs | 2 +- dotnet/Containers.ImageRefs/Components/Tag.Instances.cs | 2 +- dotnet/Containers.ImageRefs/Components/Tag.cs | 2 +- dotnet/Containers.ImageRefs/ImageId.cs | 2 +- dotnet/Containers.ImageRefs/ImageRef.cs | 4 ++-- dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs | 2 +- dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs | 4 ++-- dotnet/Containers.ImageRefs/PartialImageRef.cs | 5 ++--- dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs | 4 ++-- dotnet/Containers.ImageRefs/QualificationMode.cs | 2 +- dotnet/Containers.ImageRefs/QualifiedImageRef.cs | 4 ++-- 31 files changed, 49 insertions(+), 49 deletions(-) diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs index 6070bac..542b612 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.Extensions.Nuke; +namespace HLabs.Containers.ImageRefs.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed registries. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs index 2acf10e..b92078d 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.Extensions.Nuke; +namespace HLabs.Containers.ImageRefs.Extensions.Nuke; public static class DockerLogoutSettingsExtensions { public static DockerLogoutSettings SetServer( this DockerLogoutSettings settings, Registry registry ) { diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs index bf95021..6212b6a 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs @@ -1,6 +1,6 @@ using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.Extensions.Nuke; +namespace HLabs.Containers.ImageRefs.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed image references. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs index 3c47fc4..214c4d4 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs @@ -1,6 +1,6 @@ using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.Extensions.Nuke; +namespace HLabs.Containers.ImageRefs.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed image references. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs index 3530314..050e639 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs @@ -1,8 +1,8 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; using Nuke.Common.Tooling; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.Extensions.Nuke; +namespace HLabs.Containers.ImageRefs.Extensions.Nuke; public static class LocalDockerRepositoryExtensions { public static Digest GetDigest( this ImageId imageId ) { diff --git a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs b/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs index e254770..efae4bc 100644 --- a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class CanonicalImageRefTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/DigestTests.cs b/dotnet/Containers.ImageRefs.Tests/DigestTests.cs index fce6949..df0d1e9 100644 --- a/dotnet/Containers.ImageRefs.Tests/DigestTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/DigestTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class DigestTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs b/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs index d25d655..ae43b4e 100644 --- a/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class NamespaceTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs b/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs index 0beeed6..03fb320 100644 --- a/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs @@ -1,7 +1,8 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; +using HLabs.Containers.ImageRefs.Parsing; using Semver; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class PartialImageReferenceTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs b/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs index 5088320..7ecbce2 100644 --- a/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class QualifiedImageRefTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs b/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs index 8c8cf69..682c9aa 100644 --- a/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class RegistryTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs b/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs index 87acdc7..0da4771 100644 --- a/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class RepositoryTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/TagTests.cs b/dotnet/Containers.ImageRefs.Tests/TagTests.cs index 3b2fae7..31b705e 100644 --- a/dotnet/Containers.ImageRefs.Tests/TagTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/TagTests.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; using Semver; -namespace HLabs.Containers.Tests; +namespace HLabs.Containers.ImageRefs.Tests; internal sealed class TagTests { [Test] diff --git a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs b/dotnet/Containers.ImageRefs/CanonicalImageRef.cs index 08aad5b..814e6a5 100644 --- a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs +++ b/dotnet/Containers.ImageRefs/CanonicalImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Content-addressable (immutable) image reference. diff --git a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs b/dotnet/Containers.ImageRefs/CanonicalizationMode.cs index 89be0ef..1b0f0de 100644 --- a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs +++ b/dotnet/Containers.ImageRefs/CanonicalizationMode.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Specifies how to handle the tag when canonicalizing an image reference. diff --git a/dotnet/Containers.ImageRefs/Components/Digest.cs b/dotnet/Containers.ImageRefs/Components/Digest.cs index 65e353b..572f8d3 100644 --- a/dotnet/Containers.ImageRefs/Components/Digest.cs +++ b/dotnet/Containers.ImageRefs/Components/Digest.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a digest for a container image e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4. diff --git a/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs b/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs index 6932808..a3516e5 100644 --- a/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs +++ b/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; public sealed partial record Namespace { /// diff --git a/dotnet/Containers.ImageRefs/Components/Namespace.cs b/dotnet/Containers.ImageRefs/Components/Namespace.cs index 131582c..6939f13 100644 --- a/dotnet/Containers.ImageRefs/Components/Namespace.cs +++ b/dotnet/Containers.ImageRefs/Components/Namespace.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a namespace within a container registry (e.g., organization or user name). diff --git a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs b/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs index 91784c1..b2fbedc 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs +++ b/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; public sealed partial record Registry { /// diff --git a/dotnet/Containers.ImageRefs/Components/Registry.cs b/dotnet/Containers.ImageRefs/Components/Registry.cs index cb4f752..c14f2a0 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.cs +++ b/dotnet/Containers.ImageRefs/Components/Registry.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). diff --git a/dotnet/Containers.ImageRefs/Components/Repository.cs b/dotnet/Containers.ImageRefs/Components/Repository.cs index eec9528..ea0c895 100644 --- a/dotnet/Containers.ImageRefs/Components/Repository.cs +++ b/dotnet/Containers.ImageRefs/Components/Repository.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a repository name within a container registry namespace. diff --git a/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs b/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs index 1a87112..8f57187 100644 --- a/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs +++ b/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; public sealed partial record Tag { /// diff --git a/dotnet/Containers.ImageRefs/Components/Tag.cs b/dotnet/Containers.ImageRefs/Components/Tag.cs index 6a29f52..faed5a0 100644 --- a/dotnet/Containers.ImageRefs/Components/Tag.cs +++ b/dotnet/Containers.ImageRefs/Components/Tag.cs @@ -1,6 +1,6 @@ using Semver; -namespace HLabs.Containers.Components; +namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a tag for a container image (e.g., "latest", "1.0", "v2.3.1"). diff --git a/dotnet/Containers.ImageRefs/ImageId.cs b/dotnet/Containers.ImageRefs/ImageId.cs index 937c76b..64ad22f 100644 --- a/dotnet/Containers.ImageRefs/ImageId.cs +++ b/dotnet/Containers.ImageRefs/ImageId.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Represents a local container image ID (e.g., "sha256:a3ed95caeb02..."). diff --git a/dotnet/Containers.ImageRefs/ImageRef.cs b/dotnet/Containers.ImageRefs/ImageRef.cs index a58c00d..7c0e7c8 100644 --- a/dotnet/Containers.ImageRefs/ImageRef.cs +++ b/dotnet/Containers.ImageRefs/ImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Base class for container image references, providing common properties. diff --git a/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs b/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs index 3c6baf6..18e1fc7 100644 --- a/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs +++ b/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs.Parsing; /// /// Extension methods for working with container image references from strings. diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs b/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs index 5e12070..e9f0775 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs +++ b/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; public sealed partial record PartialImageRef { [GeneratedRegex( diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.cs b/dotnet/Containers.ImageRefs/PartialImageRef.cs index 28c4e32..cda41be 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.cs +++ b/dotnet/Containers.ImageRefs/PartialImageRef.cs @@ -1,7 +1,6 @@ -using System.Diagnostics.CodeAnalysis; -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; // TODO support platform // TODO docs diff --git a/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs b/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs index 942897b..69e8c91 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs +++ b/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Extension methods for that provide convenient qualification with different components. diff --git a/dotnet/Containers.ImageRefs/QualificationMode.cs b/dotnet/Containers.ImageRefs/QualificationMode.cs index d3f1312..7d3f801 100644 --- a/dotnet/Containers.ImageRefs/QualificationMode.cs +++ b/dotnet/Containers.ImageRefs/QualificationMode.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; /// /// Specifies how to handle missing components when qualifying a partial image reference. diff --git a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs b/dotnet/Containers.ImageRefs/QualifiedImageRef.cs index 25258e4..19b472a 100644 --- a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs +++ b/dotnet/Containers.ImageRefs/QualifiedImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.Components; +using HLabs.Containers.ImageRefs.Components; -namespace HLabs.Containers; +namespace HLabs.Containers.ImageRefs; // ----------------------------- // Qualified image reference: fully qualified (registry, namespace, repository, tag or digest) From fb7f8031ff41673a87215e8e70771d78f2dd034f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:13:04 +0100 Subject: [PATCH 13/36] f --- .editorconfig | 1 + .nuke/build.schema.json | 2 + Directory.Build.props | 2 - build/NukeBuild.Build.cs | 3 +- build/NukeBuild.CheckWarnings.cs | 34 +++++ build/NukeBuild.Clean.cs | 1 - build/NukeBuild.Pack.cs | 3 - build/Utils/BinaryLogReader.cs | 25 ++++ build/_build.csproj | 2 + .../DockerLogoutSettingsExtensions.cs | 9 ++ ....cs => LocalDockerRepositoryExtensions.cs} | 8 + .../CanonicalImageRefTests.cs | 55 +++++-- dotnet/Containers.ImageRefs/AGENTS.md | 3 +- .../Containers.ImageRefs/CanonicalImageRef.cs | 21 +++ .../CanonicalizationMode.cs | 3 +- .../Containers.ImageRefs/Components/Digest.cs | 4 +- .../Components/Registry.Instances.cs | 8 +- .../Components/Registry.cs | 1 - .../PartialImageRef.Parsing.cs | 13 ++ .../Containers.ImageRefs/PartialImageRef.cs | 137 ++++++++++++++++-- .../Containers.ImageRefs/QualifiedImageRef.cs | 17 +++ 21 files changed, 307 insertions(+), 45 deletions(-) create mode 100644 build/NukeBuild.CheckWarnings.cs create mode 100644 build/Utils/BinaryLogReader.cs rename dotnet/Containers.ImageRefs.Extensions.Nuke/{ExtensionsMore.cs => LocalDockerRepositoryExtensions.cs} (79%) diff --git a/.editorconfig b/.editorconfig index 4edcea4..3e97ad8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -248,4 +248,5 @@ 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/.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/Directory.Build.props b/Directory.Build.props index 8904e1b..aff64b8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,9 +16,7 @@ false All true - $(NoWarn);SA0001 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 13dfc9e..4d2680f 100644 --- a/build/NukeBuild.Pack.cs +++ b/build/NukeBuild.Pack.cs @@ -1,7 +1,4 @@ -using System; -using System.IO; using System.IO.Compression; -using System.Linq; using System.Xml.Linq; using Nuke.Common; using Nuke.Common.IO; 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.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs index b92078d..b84f20b 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -3,7 +3,16 @@ namespace HLabs.Containers.ImageRefs.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() ); diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs b/dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs similarity index 79% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs rename to dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs index 050e639..c238029 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/ExtensionsMore.cs +++ b/dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs @@ -4,7 +4,15 @@ namespace HLabs.Containers.ImageRefs.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 ) diff --git a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs b/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs index efae4bc..c99b22d 100644 --- a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs +++ b/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs @@ -31,8 +31,12 @@ public async Task ConstructFromPartialRef() { [Test] public async Task ConstructWithCustomRegistry() { - var canonical = new PartialImageRef( Registry.GitHub, new Namespace( "myorg" ), new Repository( "myapp" ), - null, new Digest( ValidDigest ) ) + var canonical = new PartialImageRef( + Registry.GitHub, + new Namespace( "myorg" ), + new Repository( "myapp" ), + new Digest( ValidDigest ) + ) .Canonicalize(); await Assert.That( canonical.Registry ).IsEqualTo( Registry.GitHub ); @@ -92,8 +96,12 @@ public async Task ToStringWithTagAndDigest() { [Test] public async Task ToStringWithCustomRegistry() { - var canonical = new PartialImageRef( Registry.GitHub, new Namespace( "myorg" ), new Repository( "myapp" ), - null, new Digest( ValidDigest ) ) + 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}" ); } @@ -107,8 +115,13 @@ public async Task ToStringWithoutNamespace() { [Test] public async Task ToStringShowsTagWhenPresent() { - var canonical = new PartialImageRef( Registry.GitHub, new Namespace( "myorg" ), new Repository( "myapp" ), - new Tag( "v1.0" ), new Digest( ValidDigest ) ) + 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}" ); } @@ -259,11 +272,19 @@ public async Task DifferentDigestsAreNotEqual() { [Test] public async Task DifferentRegistriesAreNotEqual() { - var a = new PartialImageRef( Registry.DockerHub, new Namespace( "library" ), new Repository( "nginx" ), - null, new Digest( ValidDigest ) ) + 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" ), - null, new Digest( ValidDigest ) ) + var b = new PartialImageRef( + Registry.GitHub, + new Namespace( "library" ), + new Repository( "nginx" ), + new Digest( ValidDigest ) + ) .Canonicalize(); await Assert.That( a ).IsNotEqualTo( b ); @@ -313,16 +334,20 @@ public async Task ParseAndQualifyMaintainsDigest() { [Test] public async Task ToStringCanBeParsedBack() { - var original = - new PartialImageRef( Registry.GitHub, new Namespace( "myorg" ), new Repository( "myapp" ), Tag.Latest, - new Digest( ValidDigest ) ) - .Canonicalize(); + 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.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 ); diff --git a/dotnet/Containers.ImageRefs/AGENTS.md b/dotnet/Containers.ImageRefs/AGENTS.md index d0e53da..241b9b0 100644 --- a/dotnet/Containers.ImageRefs/AGENTS.md +++ b/dotnet/Containers.ImageRefs/AGENTS.md @@ -62,8 +62,7 @@ dotnet/ - Keep usage examples up to date in project README.md - XML docs on all public APIs with ``, ``, ``, `` being required. - - If a parameter is non-nullable but is still checked for null, `` should - not be documented. + - 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. diff --git a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs b/dotnet/Containers.ImageRefs/CanonicalImageRef.cs index 814e6a5..5f43b7a 100644 --- a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs +++ b/dotnet/Containers.ImageRefs/CanonicalImageRef.cs @@ -7,10 +7,16 @@ namespace HLabs.Containers.ImageRefs; /// 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 digest. + /// public new Digest Digest { get; } @@ -31,21 +37,31 @@ internal CanonicalImageRef( /// /// Returns a new instance with a different cosmetic tag. /// + /// The tag to apply to the new reference. + /// A new with the specified tag. public CanonicalImageRef WithTag( Tag tag ) => new(Registry, Namespace, Repository, Digest, tag); /// /// Returns a new instance with a different registry. /// + /// The registry to use for the new reference. + /// A new with the specified registry. public CanonicalImageRef On( Registry registry ) => new(registry, Namespace, Repository, Digest, Tag); /// /// Returns a new instance with a different namespace. /// + /// The namespace to use for the new reference. + /// A new with the specified namespace. public CanonicalImageRef WithNamespace( Namespace ns ) => new(Registry, ns, Repository, Digest, Tag); + /// + /// Gets a value indicating whether this reference has a fully qualified registry. + /// Always true for canonical references as they always have a registry. + /// public override bool IsQualified => true; /// @@ -54,6 +70,11 @@ public CanonicalImageRef WithNamespace( Namespace ns ) => /// public override bool IsPinned => true; + /// + /// Returns a string representation of this canonical image reference, + /// including the registry, namespace (if present), repository, digest, and tag (if present). + /// + /// 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 diff --git a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs b/dotnet/Containers.ImageRefs/CanonicalizationMode.cs index 1b0f0de..f759818 100644 --- a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs +++ b/dotnet/Containers.ImageRefs/CanonicalizationMode.cs @@ -15,5 +15,4 @@ public enum CanonicalizationMode { /// The canonical reference will include both tag and digest. /// MaintainTag -} - +} \ No newline at end of file diff --git a/dotnet/Containers.ImageRefs/Components/Digest.cs b/dotnet/Containers.ImageRefs/Components/Digest.cs index 572f8d3..c4d7094 100644 --- a/dotnet/Containers.ImageRefs/Components/Digest.cs +++ b/dotnet/Containers.ImageRefs/Components/Digest.cs @@ -36,7 +36,7 @@ private string Value { /// Initializes a new instance of the class. /// /// The digest value. Can be in format sha256:abc123 or just abc123. - /// Thrown when is not a valid digest. + /// Thrown when is not a valid digest. public Digest( string value ) { if ( string.IsNullOrWhiteSpace( value ) ) { throw new ArgumentException( "Digest cannot be null or empty", nameof(value) ); @@ -89,7 +89,7 @@ public Digest( string value ) { /// /// The digest value. /// A new instance. - /// Thrown when is not a valid digest. + /// Thrown when is not a valid digest. public static Digest FromString( string value ) { return new(value); } diff --git a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs b/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs index b2fbedc..bf38a66 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs +++ b/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs @@ -2,22 +2,22 @@ namespace HLabs.Containers.ImageRefs.Components; public sealed partial record Registry { /// - /// Docker Hub registry (docker.io). Requires a namespace. + /// Docker Hub registry (docker.io). /// public static readonly Registry DockerHub = new("docker.io", true); /// - /// Quay.io container registry. Requires a namespace. + /// Quay.io container registry (quay.io). /// public static readonly Registry Quay = new("quay.io", true); /// - /// GitHub Container Registry (ghcr.io). Requires a namespace. + /// GitHub Container Registry (ghcr.io). /// public static readonly Registry GitHub = new("ghcr.io", true); /// - /// Local Docker registry (localhost:5000). Does not require a namespace. + /// Local registry (localhost:5000). /// public static readonly Registry Localhost = new("localhost:5000", false); diff --git a/dotnet/Containers.ImageRefs/Components/Registry.cs b/dotnet/Containers.ImageRefs/Components/Registry.cs index c14f2a0..95b0cdb 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.cs +++ b/dotnet/Containers.ImageRefs/Components/Registry.cs @@ -2,7 +2,6 @@ namespace HLabs.Containers.ImageRefs.Components; /// /// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). -/// A registry is where container images are stored and distributed from. /// /// /// diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs b/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs index e9f0775..5eedfee 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs +++ b/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs @@ -10,6 +10,13 @@ public sealed partial record PartialImageRef { )] 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 ); @@ -33,6 +40,12 @@ public static PartialImageRef Parse( string imageReference ) { 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 ) { diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.cs b/dotnet/Containers.ImageRefs/PartialImageRef.cs index cda41be..b05da2f 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.cs +++ b/dotnet/Containers.ImageRefs/PartialImageRef.cs @@ -3,22 +3,24 @@ namespace HLabs.Containers.ImageRefs; // TODO support platform -// TODO docs +// TODO add no-arg Canonicalize? /// -/// ~~A fully-qualified container image reference.~~ -/// -/// example.com:5000/team/my-app:2.0 -/// -/// Host: example.com:5000 -/// Namespace: team -/// Repository: my-app -/// Tag: 2.0 -/// +/// Partial image reference. +/// +/// +/// 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 @@ -35,26 +37,57 @@ private PartialImageRef( #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 ) { } @@ -66,6 +99,11 @@ public PartialImageRef( Repository repository, Tag tag, Digest digest ) #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 ) { } @@ -73,36 +111,88 @@ public PartialImageRef( Registry registry, Repository repository ) // Overload clashes with Namespace + Repository + Tag when using string literals (implicit conversions), so not included for now. // The more common use case is probably the other one. // TODO decide + + /// + /// 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 string representation of this image reference. + /// + /// A string representation of this image reference. public override string ToString() { var reg = Registry is null ? string.Empty : $"{Registry}/"; var ns = Namespace is null ? string.Empty : $"{Namespace}/"; @@ -111,13 +201,21 @@ public override string ToString() { return $"{reg}{ns}{Repository}{tag}{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? canonical, out string? reason ) { try { canonical = Qualify(); reason = null; return true; } +#pragma warning disable CA1031 catch ( Exception ex ) { +#pragma warning restore CA1031 canonical = null; reason = ex.Message; return false; @@ -127,24 +225,33 @@ public bool TryQualify( out QualifiedImageRef? canonical, out string? reason ) { /// /// Returns a new instance with a different cosmetic tag. /// + /// The tag to use, or null to remove the tag. + /// A new with the specified tag. public PartialImageRef With( Tag? tag ) => new(Repository, tag, Registry, Namespace, Digest); /// /// Returns a new instance with a different registry. /// + /// The registry to use, or null to remove the registry. + /// A new with the specified registry. public PartialImageRef With( Registry? registry ) => new(Repository, Tag, registry, Namespace, Digest); /// /// Returns a new instance with a different registry. /// + /// The registry to use. + /// The namespace to use. + /// 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 a different namespace. /// + /// The namespace to use, or null to remove the namespace. + /// A new with the specified namespace. public PartialImageRef With( Namespace? ns ) => // TODO remember registry/namespace requirement new(Repository, Tag, Registry, ns, Digest); @@ -152,12 +259,20 @@ public PartialImageRef With( Namespace? ns ) => /// /// Returns a new instance with a different digest. /// + /// The digest to use, or null to remove the digest. + /// A new with the specified digest. public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); + /// + /// Gets a value indicating whether this reference has all required components for qualification. + /// public override bool IsQualified => Registry != null && ( !Registry.NamespaceRequired || Namespace != null ) && ( Tag != null || Digest != null ); + /// + /// Gets a value indicating whether this reference can be qualified using default conventions. + /// public bool CanQualify => TryQualify( out _, out _ ); /// @@ -182,7 +297,6 @@ public QualifiedImageRef Qualify( QualificationMode mode = QualificationMode.Def return new QualifiedImageRef( registry, ns, repo, tag, Digest ); } - /// /// Canonicalizes the image reference using the current digest. /// @@ -197,7 +311,6 @@ public CanonicalImageRef Canonicalize( return Qualify( qualificationMode ).Canonicalize( canonicalizationMode ); } - /// /// Canonicalizes the image reference with a specific digest. /// diff --git a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs b/dotnet/Containers.ImageRefs/QualifiedImageRef.cs index 19b472a..f6de947 100644 --- a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs +++ b/dotnet/Containers.ImageRefs/QualifiedImageRef.cs @@ -12,6 +12,9 @@ namespace HLabs.Containers.ImageRefs; /// Must have either a tag or digest. /// public sealed record QualifiedImageRef : ImageRef { + /// + /// Gets the registry for this image reference. + /// public new Registry Registry { get; } @@ -32,12 +35,17 @@ internal QualifiedImageRef( Registry registry, Namespace? ns, Repository reposit /// /// Returns a new instance with a different tag. /// + /// The tag to use. + /// A new with the specified tag. public QualifiedImageRef WithTag( Tag tag ) => new(Registry, Namespace, Repository, tag, Digest); /// /// Returns a new instance with a different registry. /// + /// The registry to use. + /// Optional namespace to use; if not provided, uses the current namespace. + /// A new with the specified registry and namespace. public QualifiedImageRef On( Registry registry, Namespace? ns = null ) => new(registry, ns ?? Namespace, Repository, Tag, Digest); @@ -59,6 +67,7 @@ public CanonicalImageRef Canonicalize( Digest digest, CanonicalizationMode mode /// Creates a digest-pinned image reference using the current digest. /// /// Specifies whether to maintain or exclude the tag in the canonical reference. + /// A canonical (immutable) image reference. /// Thrown when digest is not present. public CanonicalImageRef Canonicalize( CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { if ( Digest is null ) { @@ -70,8 +79,16 @@ public CanonicalImageRef Canonicalize( CanonicalizationMode mode = Canonicalizat return new CanonicalImageRef( Registry, Namespace, Repository, Digest, tag ); } + /// + /// Gets a value indicating whether this reference has a fully qualified registry. + /// Always true for qualified references as they always have a registry. + /// public override bool IsQualified => true; + /// + /// Returns a 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; From dae9709509633409ab3fa1ddae858d1d2de2537c Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:30:33 +0100 Subject: [PATCH 14/36] rename --- AGENTS.md | 4 ++-- Directory.Build.props | 2 +- HLabs.slnx | 6 +++--- README.md | 8 ++++---- .../DockerLoginSettingsExtensions.cs | 4 ++-- .../DockerLogoutSettingsExtensions.cs | 4 ++-- .../DockerPushSettingsExtensions.cs | 2 +- .../DockerTagSettingsExtensions.cs | 2 +- .../ImageReferences.Extensions.Nuke.csproj} | 2 +- .../LocalDockerRepositoryExtensions.cs | 4 ++-- .../README.md | 4 ++-- .../CanonicalImageRefTests.cs | 4 ++-- .../DigestTests.cs | 4 ++-- .../ImageReferences.Tests.csproj} | 2 +- .../NamespaceTests.cs | 4 ++-- .../PartialImageReferenceTests.cs | 6 +++--- .../QualifiedImageRefTests.cs | 4 ++-- .../RegistryTests.cs | 4 ++-- .../RepositoryTests.cs | 4 ++-- .../TagTests.cs | 4 ++-- .../{Containers.ImageRefs => ImageReferences}/AGENTS.md | 0 .../CanonicalImageRef.cs | 4 ++-- .../CanonicalizationMode.cs | 2 +- .../Components/Digest.cs | 2 +- .../Components/Namespace.Instances.cs | 2 +- .../Components/Namespace.cs | 2 +- .../Components/Registry.Instances.cs | 2 +- .../Components/Registry.cs | 2 +- .../Components/Repository.cs | 2 +- .../Components/Tag.Instances.cs | 2 +- .../Components/Tag.cs | 2 +- .../{Containers.ImageRefs => ImageReferences}/ImageId.cs | 2 +- .../{Containers.ImageRefs => ImageReferences}/ImageRef.cs | 4 ++-- .../ImageReferences.csproj} | 0 .../Parsing/StringExtensions.cs | 2 +- .../PartialImageRef.Parsing.cs | 4 ++-- .../PartialImageRef.cs | 4 ++-- .../PartialImageRefExtensions.cs | 4 ++-- .../QualificationMode.cs | 2 +- .../QualifiedImageRef.cs | 4 ++-- .../{Containers.ImageRefs => ImageReferences}/README.md | 4 ++-- 41 files changed, 65 insertions(+), 65 deletions(-) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/DockerLoginSettingsExtensions.cs (88%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/DockerLogoutSettingsExtensions.cs (88%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/DockerPushSettingsExtensions.cs (93%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/DockerTagSettingsExtensions.cs (97%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj => ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj} (84%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/LocalDockerRepositoryExtensions.cs (93%) rename dotnet/{Containers.ImageRefs.Extensions.Nuke => ImageReferences.Extensions.Nuke}/README.md (50%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/CanonicalImageRefTests.cs (99%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/DigestTests.cs (95%) rename dotnet/{Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj => ImageReferences.Tests/ImageReferences.Tests.csproj} (63%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/NamespaceTests.cs (91%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/PartialImageReferenceTests.cs (99%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/QualifiedImageRefTests.cs (99%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/RegistryTests.cs (95%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/RepositoryTests.cs (93%) rename dotnet/{Containers.ImageRefs.Tests => ImageReferences.Tests}/TagTests.cs (95%) rename dotnet/{Containers.ImageRefs => ImageReferences}/AGENTS.md (100%) rename dotnet/{Containers.ImageRefs => ImageReferences}/CanonicalImageRef.cs (97%) rename dotnet/{Containers.ImageRefs => ImageReferences}/CanonicalizationMode.cs (92%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Digest.cs (98%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Namespace.Instances.cs (78%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Namespace.cs (98%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Registry.Instances.cs (96%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Registry.cs (97%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Repository.cs (97%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Tag.Instances.cs (81%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Components/Tag.cs (98%) rename dotnet/{Containers.ImageRefs => ImageReferences}/ImageId.cs (98%) rename dotnet/{Containers.ImageRefs => ImageReferences}/ImageRef.cs (96%) rename dotnet/{Containers.ImageRefs/Containers.ImageRefs.csproj => ImageReferences/ImageReferences.csproj} (100%) rename dotnet/{Containers.ImageRefs => ImageReferences}/Parsing/StringExtensions.cs (97%) rename dotnet/{Containers.ImageRefs => ImageReferences}/PartialImageRef.Parsing.cs (96%) rename dotnet/{Containers.ImageRefs => ImageReferences}/PartialImageRef.cs (99%) rename dotnet/{Containers.ImageRefs => ImageReferences}/PartialImageRefExtensions.cs (96%) rename dotnet/{Containers.ImageRefs => ImageReferences}/QualificationMode.cs (91%) rename dotnet/{Containers.ImageRefs => ImageReferences}/QualifiedImageRef.cs (98%) rename dotnet/{Containers.ImageRefs => ImageReferences}/README.md (98%) diff --git a/AGENTS.md b/AGENTS.md index 49a8c48..2334082 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Project structure -- `dotnet/Containers/` - Build, parse, and manipulate container image references -- `dotnet/Containers.Extensions.Nuke/` - NUKE build extensions +- `dotnet/Containers.ImageReferences/` - Build, parse, and manipulate container image references +- `dotnet/Containers.ImageReferences.Extensions.Nuke/` - NUKE build extensions Each project may have: diff --git a/Directory.Build.props b/Directory.Build.props index aff64b8..e784c80 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 0.0.0-local + 1.0.0-preview.1 hojmark Copyright (c) hojmark https://github.com/hojmark/labs diff --git a/HLabs.slnx b/HLabs.slnx index 531867a..aa0e3bc 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -15,7 +15,7 @@ - - - + + + diff --git a/README.md b/README.md index 5ffb0e1..ef2a36b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## NuGet packages -| Name | Version | -|-----------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------| -| [HLabs.Containers](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.svg)](https://www.nuget.org/packages/HLabs.Containers/) | -| [HLabs.Containers.Extensions.Nuke](dotnet/Containers/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.Containers.Extensions.Nuke.svg)](https://www.nuget.org/packages/HLabs.Containers.Extensions.Nuke/) | \ No newline at end of file +| Name | Version | +|---------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| +| [HLabs.ImageReferences](dotnet/ImageReferences/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.svg)](https://www.nuget.org/packages/HLabs.ImageReferences/) | +| [HLabs.ImageReferences.Extensions.Nuke](dotnet/ImageReferences/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.Extensions.Nuke.svg)](https://www.nuget.org/packages/HLabs.ImageReferences.Extensions.Nuke/) | \ No newline at end of file diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs similarity index 88% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs rename to dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs index 542b612..97eb4e3 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLoginSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.ImageRefs.Extensions.Nuke; +namespace HLabs.ImageReferences.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed registries. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs similarity index 88% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs rename to dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs index b84f20b..0dd6b87 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerLogoutSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.ImageRefs.Extensions.Nuke; +namespace HLabs.ImageReferences.Extensions.Nuke; /// /// Extension methods for to work with type-safe objects. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs similarity index 93% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs rename to dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs index 6212b6a..1f6820e 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerPushSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerPushSettingsExtensions.cs @@ -1,6 +1,6 @@ using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.ImageRefs.Extensions.Nuke; +namespace HLabs.ImageReferences.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed image references. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs similarity index 97% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs rename to dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs index 214c4d4..00cac49 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/DockerTagSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerTagSettingsExtensions.cs @@ -1,6 +1,6 @@ using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.ImageRefs.Extensions.Nuke; +namespace HLabs.ImageReferences.Extensions.Nuke; /// /// Extension methods for to work with strongly-typed image references. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj similarity index 84% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj rename to dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj index e4841f6..20e0957 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/Containers.ImageRefs.Extensions.Nuke.csproj +++ b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj @@ -15,7 +15,7 @@ - + diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs similarity index 93% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs rename to dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs index c238029..262d29e 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/LocalDockerRepositoryExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs @@ -1,8 +1,8 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; using Nuke.Common.Tooling; using Nuke.Common.Tools.Docker; -namespace HLabs.Containers.ImageRefs.Extensions.Nuke; +namespace HLabs.ImageReferences.Extensions.Nuke; /// /// Extension methods for working with local Docker repository to retrieve image metadata. diff --git a/dotnet/Containers.ImageRefs.Extensions.Nuke/README.md b/dotnet/ImageReferences.Extensions.Nuke/README.md similarity index 50% rename from dotnet/Containers.ImageRefs.Extensions.Nuke/README.md rename to dotnet/ImageReferences.Extensions.Nuke/README.md index a44489e..599f8ea 100644 --- a/dotnet/Containers.ImageRefs.Extensions.Nuke/README.md +++ b/dotnet/ImageReferences.Extensions.Nuke/README.md @@ -1,3 +1,3 @@ -# HLabs.Containers.ImageRefs.Extensions.Nuke +# HLabs.Containers.ImageReferences.Extensions.Nuke -This repository contains NUKE extensions for HLabs.Containers.ImageRefs. \ No newline at end of file +This repository contains NUKE extensions for HLabs.Containers.ImageReferences. \ No newline at end of file diff --git a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs similarity index 99% rename from dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs rename to dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs index c99b22d..5b136d5 100644 --- a/dotnet/Containers.ImageRefs.Tests/CanonicalImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class CanonicalImageRefTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/DigestTests.cs b/dotnet/ImageReferences.Tests/DigestTests.cs similarity index 95% rename from dotnet/Containers.ImageRefs.Tests/DigestTests.cs rename to dotnet/ImageReferences.Tests/DigestTests.cs index df0d1e9..12b25e1 100644 --- a/dotnet/Containers.ImageRefs.Tests/DigestTests.cs +++ b/dotnet/ImageReferences.Tests/DigestTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class DigestTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj b/dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj similarity index 63% rename from dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj rename to dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj index 1af31c2..26e38c0 100644 --- a/dotnet/Containers.ImageRefs.Tests/Containers.ImageRefs.Tests.csproj +++ b/dotnet/ImageReferences.Tests/ImageReferences.Tests.csproj @@ -1,7 +1,7 @@  - + diff --git a/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs b/dotnet/ImageReferences.Tests/NamespaceTests.cs similarity index 91% rename from dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs rename to dotnet/ImageReferences.Tests/NamespaceTests.cs index ae43b4e..f28362f 100644 --- a/dotnet/Containers.ImageRefs.Tests/NamespaceTests.cs +++ b/dotnet/ImageReferences.Tests/NamespaceTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class NamespaceTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs similarity index 99% rename from dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs rename to dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs index 03fb320..06b78d7 100644 --- a/dotnet/Containers.ImageRefs.Tests/PartialImageReferenceTests.cs +++ b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs @@ -1,8 +1,8 @@ -using HLabs.Containers.ImageRefs.Components; -using HLabs.Containers.ImageRefs.Parsing; +using HLabs.ImageReferences.Components; +using HLabs.ImageReferences.Parsing; using Semver; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class PartialImageReferenceTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs similarity index 99% rename from dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs rename to dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs index 7ecbce2..8238eb3 100644 --- a/dotnet/Containers.ImageRefs.Tests/QualifiedImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class QualifiedImageRefTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs b/dotnet/ImageReferences.Tests/RegistryTests.cs similarity index 95% rename from dotnet/Containers.ImageRefs.Tests/RegistryTests.cs rename to dotnet/ImageReferences.Tests/RegistryTests.cs index 682c9aa..f7255dd 100644 --- a/dotnet/Containers.ImageRefs.Tests/RegistryTests.cs +++ b/dotnet/ImageReferences.Tests/RegistryTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class RegistryTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs b/dotnet/ImageReferences.Tests/RepositoryTests.cs similarity index 93% rename from dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs rename to dotnet/ImageReferences.Tests/RepositoryTests.cs index 0da4771..8b8ee99 100644 --- a/dotnet/Containers.ImageRefs.Tests/RepositoryTests.cs +++ b/dotnet/ImageReferences.Tests/RepositoryTests.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class RepositoryTests { [Test] diff --git a/dotnet/Containers.ImageRefs.Tests/TagTests.cs b/dotnet/ImageReferences.Tests/TagTests.cs similarity index 95% rename from dotnet/Containers.ImageRefs.Tests/TagTests.cs rename to dotnet/ImageReferences.Tests/TagTests.cs index 31b705e..0756f8f 100644 --- a/dotnet/Containers.ImageRefs.Tests/TagTests.cs +++ b/dotnet/ImageReferences.Tests/TagTests.cs @@ -1,7 +1,7 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; using Semver; -namespace HLabs.Containers.ImageRefs.Tests; +namespace HLabs.ImageReferences.Tests; internal sealed class TagTests { [Test] diff --git a/dotnet/Containers.ImageRefs/AGENTS.md b/dotnet/ImageReferences/AGENTS.md similarity index 100% rename from dotnet/Containers.ImageRefs/AGENTS.md rename to dotnet/ImageReferences/AGENTS.md diff --git a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs similarity index 97% rename from dotnet/Containers.ImageRefs/CanonicalImageRef.cs rename to dotnet/ImageReferences/CanonicalImageRef.cs index 5f43b7a..cbba7d8 100644 --- a/dotnet/Containers.ImageRefs/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Content-addressable (immutable) image reference. diff --git a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs b/dotnet/ImageReferences/CanonicalizationMode.cs similarity index 92% rename from dotnet/Containers.ImageRefs/CanonicalizationMode.cs rename to dotnet/ImageReferences/CanonicalizationMode.cs index f759818..644c1fd 100644 --- a/dotnet/Containers.ImageRefs/CanonicalizationMode.cs +++ b/dotnet/ImageReferences/CanonicalizationMode.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Specifies how to handle the tag when canonicalizing an image reference. diff --git a/dotnet/Containers.ImageRefs/Components/Digest.cs b/dotnet/ImageReferences/Components/Digest.cs similarity index 98% rename from dotnet/Containers.ImageRefs/Components/Digest.cs rename to dotnet/ImageReferences/Components/Digest.cs index c4d7094..5a6a741 100644 --- a/dotnet/Containers.ImageRefs/Components/Digest.cs +++ b/dotnet/ImageReferences/Components/Digest.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; /// /// Represents a digest for a container image e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4. diff --git a/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs b/dotnet/ImageReferences/Components/Namespace.Instances.cs similarity index 78% rename from dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs rename to dotnet/ImageReferences/Components/Namespace.Instances.cs index a3516e5..381ba4a 100644 --- a/dotnet/Containers.ImageRefs/Components/Namespace.Instances.cs +++ b/dotnet/ImageReferences/Components/Namespace.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; public sealed partial record Namespace { /// diff --git a/dotnet/Containers.ImageRefs/Components/Namespace.cs b/dotnet/ImageReferences/Components/Namespace.cs similarity index 98% rename from dotnet/Containers.ImageRefs/Components/Namespace.cs rename to dotnet/ImageReferences/Components/Namespace.cs index 6939f13..d0c3d0c 100644 --- a/dotnet/Containers.ImageRefs/Components/Namespace.cs +++ b/dotnet/ImageReferences/Components/Namespace.cs @@ -1,6 +1,6 @@ using System.Diagnostics.CodeAnalysis; -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; /// /// Represents a namespace within a container registry (e.g., organization or user name). diff --git a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs b/dotnet/ImageReferences/Components/Registry.Instances.cs similarity index 96% rename from dotnet/Containers.ImageRefs/Components/Registry.Instances.cs rename to dotnet/ImageReferences/Components/Registry.Instances.cs index bf38a66..0ed2f88 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.Instances.cs +++ b/dotnet/ImageReferences/Components/Registry.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; public sealed partial record Registry { /// diff --git a/dotnet/Containers.ImageRefs/Components/Registry.cs b/dotnet/ImageReferences/Components/Registry.cs similarity index 97% rename from dotnet/Containers.ImageRefs/Components/Registry.cs rename to dotnet/ImageReferences/Components/Registry.cs index 95b0cdb..b8a7de9 100644 --- a/dotnet/Containers.ImageRefs/Components/Registry.cs +++ b/dotnet/ImageReferences/Components/Registry.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; /// /// Represents a container registry host (e.g., "docker.io", "ghcr.io", "localhost:5000"). diff --git a/dotnet/Containers.ImageRefs/Components/Repository.cs b/dotnet/ImageReferences/Components/Repository.cs similarity index 97% rename from dotnet/Containers.ImageRefs/Components/Repository.cs rename to dotnet/ImageReferences/Components/Repository.cs index ea0c895..cc842ab 100644 --- a/dotnet/Containers.ImageRefs/Components/Repository.cs +++ b/dotnet/ImageReferences/Components/Repository.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; /// /// Represents a repository name within a container registry namespace. diff --git a/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs b/dotnet/ImageReferences/Components/Tag.Instances.cs similarity index 81% rename from dotnet/Containers.ImageRefs/Components/Tag.Instances.cs rename to dotnet/ImageReferences/Components/Tag.Instances.cs index 8f57187..44b775a 100644 --- a/dotnet/Containers.ImageRefs/Components/Tag.Instances.cs +++ b/dotnet/ImageReferences/Components/Tag.Instances.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; public sealed partial record Tag { /// diff --git a/dotnet/Containers.ImageRefs/Components/Tag.cs b/dotnet/ImageReferences/Components/Tag.cs similarity index 98% rename from dotnet/Containers.ImageRefs/Components/Tag.cs rename to dotnet/ImageReferences/Components/Tag.cs index faed5a0..028279f 100644 --- a/dotnet/Containers.ImageRefs/Components/Tag.cs +++ b/dotnet/ImageReferences/Components/Tag.cs @@ -1,6 +1,6 @@ using Semver; -namespace HLabs.Containers.ImageRefs.Components; +namespace HLabs.ImageReferences; /// /// Represents a tag for a container image (e.g., "latest", "1.0", "v2.3.1"). diff --git a/dotnet/Containers.ImageRefs/ImageId.cs b/dotnet/ImageReferences/ImageId.cs similarity index 98% rename from dotnet/Containers.ImageRefs/ImageId.cs rename to dotnet/ImageReferences/ImageId.cs index 64ad22f..77c7d7c 100644 --- a/dotnet/Containers.ImageRefs/ImageId.cs +++ b/dotnet/ImageReferences/ImageId.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Represents a local container image ID (e.g., "sha256:a3ed95caeb02..."). diff --git a/dotnet/Containers.ImageRefs/ImageRef.cs b/dotnet/ImageReferences/ImageRef.cs similarity index 96% rename from dotnet/Containers.ImageRefs/ImageRef.cs rename to dotnet/ImageReferences/ImageRef.cs index 7c0e7c8..52e34a6 100644 --- a/dotnet/Containers.ImageRefs/ImageRef.cs +++ b/dotnet/ImageReferences/ImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Base class for container image references, providing common properties. diff --git a/dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj b/dotnet/ImageReferences/ImageReferences.csproj similarity index 100% rename from dotnet/Containers.ImageRefs/Containers.ImageRefs.csproj rename to dotnet/ImageReferences/ImageReferences.csproj diff --git a/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs b/dotnet/ImageReferences/Parsing/StringExtensions.cs similarity index 97% rename from dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs rename to dotnet/ImageReferences/Parsing/StringExtensions.cs index 18e1fc7..cd7fa51 100644 --- a/dotnet/Containers.ImageRefs/Parsing/StringExtensions.cs +++ b/dotnet/ImageReferences/Parsing/StringExtensions.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs.Parsing; +namespace HLabs.ImageReferences; /// /// Extension methods for working with container image references from strings. diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs b/dotnet/ImageReferences/PartialImageRef.Parsing.cs similarity index 96% rename from dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs rename to dotnet/ImageReferences/PartialImageRef.Parsing.cs index 5eedfee..bfe9e2a 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.Parsing.cs +++ b/dotnet/ImageReferences/PartialImageRef.Parsing.cs @@ -1,7 +1,7 @@ using System.Text.RegularExpressions; -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; public sealed partial record PartialImageRef { [GeneratedRegex( diff --git a/dotnet/Containers.ImageRefs/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs similarity index 99% rename from dotnet/Containers.ImageRefs/PartialImageRef.cs rename to dotnet/ImageReferences/PartialImageRef.cs index b05da2f..024c21b 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; // TODO support platform // TODO add no-arg Canonicalize? diff --git a/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs b/dotnet/ImageReferences/PartialImageRefExtensions.cs similarity index 96% rename from dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs rename to dotnet/ImageReferences/PartialImageRefExtensions.cs index 69e8c91..9d75e45 100644 --- a/dotnet/Containers.ImageRefs/PartialImageRefExtensions.cs +++ b/dotnet/ImageReferences/PartialImageRefExtensions.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Extension methods for that provide convenient qualification with different components. diff --git a/dotnet/Containers.ImageRefs/QualificationMode.cs b/dotnet/ImageReferences/QualificationMode.cs similarity index 91% rename from dotnet/Containers.ImageRefs/QualificationMode.cs rename to dotnet/ImageReferences/QualificationMode.cs index 7d3f801..5c161da 100644 --- a/dotnet/Containers.ImageRefs/QualificationMode.cs +++ b/dotnet/ImageReferences/QualificationMode.cs @@ -1,4 +1,4 @@ -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; /// /// Specifies how to handle missing components when qualifying a partial image reference. diff --git a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs similarity index 98% rename from dotnet/Containers.ImageRefs/QualifiedImageRef.cs rename to dotnet/ImageReferences/QualifiedImageRef.cs index f6de947..531e243 100644 --- a/dotnet/Containers.ImageRefs/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -1,6 +1,6 @@ -using HLabs.Containers.ImageRefs.Components; +using HLabs.ImageReferences.Components; -namespace HLabs.Containers.ImageRefs; +namespace HLabs.ImageReferences; // ----------------------------- // Qualified image reference: fully qualified (registry, namespace, repository, tag or digest) diff --git a/dotnet/Containers.ImageRefs/README.md b/dotnet/ImageReferences/README.md similarity index 98% rename from dotnet/Containers.ImageRefs/README.md rename to dotnet/ImageReferences/README.md index 3c0f2f6..d6dfc51 100644 --- a/dotnet/Containers.ImageRefs/README.md +++ b/dotnet/ImageReferences/README.md @@ -1,4 +1,4 @@ -# HLabs.Containers.ImageRefs +# HLabs.ImageReferences Strongly-typed, validated container image references for .NET. @@ -11,7 +11,7 @@ Build, parse, and manipulate Docker/OCI image references (like `docker.io/librar Install via NuGet: ```bash -dotnet add package HLabs.Containers.ImageRefs +dotnet add package HLabs.ImageReferences ``` ## Getting Started From 4e2972907b3c55b568663fc9f4d0465cff380c7a Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:23:07 +0100 Subject: [PATCH 15/36] f --- README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ef2a36b..6bf3024 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ +# HLabs + +A collection of .NET libraries. + ## NuGet packages -| Name | Version | -|---------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| [HLabs.ImageReferences](dotnet/ImageReferences/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.svg)](https://www.nuget.org/packages/HLabs.ImageReferences/) | -| [HLabs.ImageReferences.Extensions.Nuke](dotnet/ImageReferences/README.md) | [![NuGet](https://img.shields.io/nuget/vpre/HLabs.ImageReferences.Extensions.Nuke.svg)](https://www.nuget.org/packages/HLabs.ImageReferences.Extensions.Nuke/) | \ No newline at end of file +| 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 | From 7504165bb819219003294f02ebda6746350fd478 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:44:51 +0100 Subject: [PATCH 16/36] f --- dotnet/ImageReferences/AGENTS.md | 1 + dotnet/ImageReferences/README.md | 107 ++++++++----------------------- 2 files changed, 28 insertions(+), 80 deletions(-) diff --git a/dotnet/ImageReferences/AGENTS.md b/dotnet/ImageReferences/AGENTS.md index 241b9b0..6becd8e 100644 --- a/dotnet/ImageReferences/AGENTS.md +++ b/dotnet/ImageReferences/AGENTS.md @@ -57,6 +57,7 @@ dotnet/ - 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 diff --git a/dotnet/ImageReferences/README.md b/dotnet/ImageReferences/README.md index d6dfc51..1f03f61 100644 --- a/dotnet/ImageReferences/README.md +++ b/dotnet/ImageReferences/README.md @@ -1,14 +1,6 @@ # HLabs.ImageReferences -Strongly-typed, validated container image references for .NET. - -Build, parse, and manipulate Docker/OCI image references (like `docker.io/library/nginx:1.25`). - ---- - -## Installation - -Install via NuGet: +Strongly-typed container image references for .NET. ```bash dotnet add package HLabs.ImageReferences @@ -17,83 +9,33 @@ dotnet add package HLabs.ImageReferences ## Getting Started ```csharp -var image = new ImageReference("nginx"); -Console.WriteLine(image); // docker.io/library/nginx -``` - -### Building references - -```csharp -// Implicit construction -var image = new ImageReference("nginx", "trixie"); // → docker.io/library/nginx:trixie - -// Explicit construction -var image = new ImageReference(new Repository("nginx"), new Tag("trixie")); // → docker.io/library/nginx:trixie - -// Semantic Versioning support (via the Semver package) -var image = new ImageReference("myapp", new SemVersion(3, 1, 0), Registry.GitHub, "myorg"); // → ghcr.io/myorg/myapp:3.1.0 - - - +var partial = "nginx".Image(); // -> nginx +var qualified = partial.Qualified(); // docker.io/library/nginx:latest -// Minimal — no tag, no digest -var image = new ImageReference("nginx"); // → docker.io/library/nginx - -// With an explicit tag -var image = new ImageReference("nginx", Tag.Latest); // → docker.io/library/nginx:latest var image = new ImageReference("nginx", "trixie"); // → docker.io/library/nginx:trixie - -// Digest-only (no tag) -var image = new ImageReference("nginx", digest: new Digest("sha256:a3ed95caeb02...")); // → docker.io/library/nginx@sha256:a3ed95caeb02... - -// Tag + digest -var image = new ImageReference("nginx", "trixie", digest: new Digest("sha256:a3ed95caeb02...")); // → docker.io/library/nginx:trixie@sha256:a3ed95caeb02... - -// Semantic Versioning support (via the Semver package) -var image = new ImageReference("myapp", new SemVersion(3, 1, 0), Registry.GitHub, "myorg"); // → ghcr.io/myorg/myapp:3.1.0 - +var canonical = partial.Canonical("57e903..."); // docker.io/library/nginx@sha256:57e903... ``` ### Parsing references ```csharp -var image = ImageReference.Parse("ghcr.io/myorg/myapp:3.1.0"); -Console.WriteLine(image.Registry); // ghcr.io -Console.WriteLine(image.Namespace); // myorg -Console.WriteLine(image.Repository); // myapp -Console.WriteLine(image.Tag); // 3.1.0 - -// Tag is null when not present in the input -var image = ImageReference.Parse("docker.io/library/nginx@sha256:abc123"); -Console.WriteLine(image.Tag); // (null) -Console.WriteLine(image.Digest); // sha256:abc123 -``` - -### `IsCanonical` and `IsImmutable` +var image = "ghcr.io/myorg/myapp:3.1.0".Image(); -```csharp -var bare = new ImageReference("nginx"); // no tag, no digest -bare.IsCanonical; // false — not enough info to identify a specific image -bare.IsImmutable; // false - -var tagged = new ImageReference("nginx", Tag.Latest); // tag only -tagged.IsCanonical; // true — tag resolves to an image (but may change over time) -tagged.IsImmutable; // false — tags are mutable; "latest" can point to different images - -var pinned = new ImageReference("nginx", digest: new Digest("sha256:abc123")); // digest only -pinned.IsCanonical; // true -pinned.IsImmutable; // true — digests are content-addressable and fixed +Registry reg = image.Registry; // ghcr.io +Namespace ns = image.Namespace; // myorg +Repository repo = image.Repository; // myapp +Tag tag = image.Tag; // 3.1.0 ``` -### Modifying using `with` expressions +### Modifying references -`ImageReference` is a C# `record`, so you can create modified copies using `with`: +You are always guarenteed to have a structurally valid reference when modifying: ```csharp -var dev = new ImageReference("myapp", Tag.Latest, Registry.Localhost); // → localhost:5000/myapp:latest -var prod = dev with { Registry = Registry.DockerHub, Namespace = "myorg" }; // → docker.io/myorg/myapp:latest -var pinned = prod with { Tag = new SemVersion(2, 1, 0) }; // → docker.io/myorg/myapp:2.1.0 -var withDigest = pinned with { Digest = "sha256:a3ed95caeb02..." }; // → docker.io/myorg/myapp:2.1.0@sha256:a3ed95caeb02... +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 @@ -106,7 +48,7 @@ Registry.GitHub // ghcr.io Registry.Quay // quay.io Registry.Localhost // localhost:5000 Registry.Acr("mycompany") // mycompany.azurecr.io -Registry.Ecr("123456789", "eu-west-1") // 123456789.dkr.ecr.eu-west-1.amazonaws.com +Registry.Ecr("1234", "eu-west-1") // 1234.dkr.ecr.eu-west-1.amazonaws.com Tag.Latest // latest ``` @@ -116,12 +58,17 @@ Tag.Latest // latest Define your own well-known tags (or registries, repositories, etc.) using C# 14 extensions: ```csharp -internal static class MyTagExtensions +static class MyExtensions { extension(Tag) { - public static Tag Dev => new("dev"); - public static Tag Alpha(uint n) => new($"alpha-{n}"); + static Tag Dev => new("dev"); + static Tag Alpha(uint n) => new($"alpha-{n}"); + } + + extension(Registry) + { + static Registry Internal => Registry.Ecr("1234", "eu-west-1") } } ``` @@ -129,6 +76,6 @@ internal static class MyTagExtensions Then use them naturally: ```csharp -var image = new ImageReference("myapp", Tag.Dev, Registry.Localhost); // → localhost:5000/myapp:dev -var alpha = image with { Tag = Tag.Alpha(3) }; // → localhost:5000/myapp:alpha-3 -``` \ No newline at end of file +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 +``` From 17caffc8c3e33fa3e314809596e2094f3a28105f Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:49:38 +0100 Subject: [PATCH 17/36] compile --- .../DockerLoginSettingsExtensions.cs | 3 +- .../DockerLogoutSettingsExtensions.cs | 3 +- .../LocalDockerRepositoryExtensions.cs | 1 - .../CanonicalImageRefTests.cs | 2 - dotnet/ImageReferences.Tests/DigestTests.cs | 2 - .../ImageReferences.Tests/NamespaceTests.cs | 2 - .../PartialImageReferenceTests.cs | 4 +- .../QualifiedImageRefTests.cs | 2 - dotnet/ImageReferences.Tests/RegistryTests.cs | 2 - .../ImageReferences.Tests/RepositoryTests.cs | 2 - dotnet/ImageReferences.Tests/TagTests.cs | 1 - dotnet/ImageReferences/CanonicalImageRef.cs | 2 - dotnet/ImageReferences/ImageRef.cs | 2 - .../PartialImageRef.Parsing.cs | 1 - dotnet/ImageReferences/PartialImageRef.cs | 177 +++++++++++++++++- dotnet/ImageReferences/QualifiedImageRef.cs | 2 - 16 files changed, 178 insertions(+), 30 deletions(-) diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs index 97eb4e3..001a6a6 100644 --- a/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLoginSettingsExtensions.cs @@ -1,5 +1,4 @@ -using HLabs.ImageReferences.Components; -using Nuke.Common.Tools.Docker; +using Nuke.Common.Tools.Docker; namespace HLabs.ImageReferences.Extensions.Nuke; diff --git a/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs index 0dd6b87..2fc255f 100644 --- a/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/DockerLogoutSettingsExtensions.cs @@ -1,5 +1,4 @@ -using HLabs.ImageReferences.Components; -using Nuke.Common.Tools.Docker; +using Nuke.Common.Tools.Docker; namespace HLabs.ImageReferences.Extensions.Nuke; diff --git a/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs index 262d29e..51692f9 100644 --- a/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs +++ b/dotnet/ImageReferences.Extensions.Nuke/LocalDockerRepositoryExtensions.cs @@ -1,4 +1,3 @@ -using HLabs.ImageReferences.Components; using Nuke.Common.Tooling; using Nuke.Common.Tools.Docker; diff --git a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs index 5b136d5..2b98de6 100644 --- a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class CanonicalImageRefTests { diff --git a/dotnet/ImageReferences.Tests/DigestTests.cs b/dotnet/ImageReferences.Tests/DigestTests.cs index 12b25e1..ddaf9fb 100644 --- a/dotnet/ImageReferences.Tests/DigestTests.cs +++ b/dotnet/ImageReferences.Tests/DigestTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class DigestTests { diff --git a/dotnet/ImageReferences.Tests/NamespaceTests.cs b/dotnet/ImageReferences.Tests/NamespaceTests.cs index f28362f..a0be940 100644 --- a/dotnet/ImageReferences.Tests/NamespaceTests.cs +++ b/dotnet/ImageReferences.Tests/NamespaceTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class NamespaceTests { diff --git a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs index 06b78d7..9d4bda6 100644 --- a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs +++ b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs @@ -1,6 +1,4 @@ -using HLabs.ImageReferences.Components; -using HLabs.ImageReferences.Parsing; -using Semver; +using Semver; namespace HLabs.ImageReferences.Tests; diff --git a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs index 8238eb3..71e7216 100644 --- a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class QualifiedImageRefTests { diff --git a/dotnet/ImageReferences.Tests/RegistryTests.cs b/dotnet/ImageReferences.Tests/RegistryTests.cs index f7255dd..0ff9186 100644 --- a/dotnet/ImageReferences.Tests/RegistryTests.cs +++ b/dotnet/ImageReferences.Tests/RegistryTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class RegistryTests { diff --git a/dotnet/ImageReferences.Tests/RepositoryTests.cs b/dotnet/ImageReferences.Tests/RepositoryTests.cs index 8b8ee99..e7069bb 100644 --- a/dotnet/ImageReferences.Tests/RepositoryTests.cs +++ b/dotnet/ImageReferences.Tests/RepositoryTests.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences.Tests; internal sealed class RepositoryTests { diff --git a/dotnet/ImageReferences.Tests/TagTests.cs b/dotnet/ImageReferences.Tests/TagTests.cs index 0756f8f..38a0b2f 100644 --- a/dotnet/ImageReferences.Tests/TagTests.cs +++ b/dotnet/ImageReferences.Tests/TagTests.cs @@ -1,4 +1,3 @@ -using HLabs.ImageReferences.Components; using Semver; namespace HLabs.ImageReferences.Tests; diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs index cbba7d8..c4ef38b 100644 --- a/dotnet/ImageReferences/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences; /// diff --git a/dotnet/ImageReferences/ImageRef.cs b/dotnet/ImageReferences/ImageRef.cs index 52e34a6..ff46c67 100644 --- a/dotnet/ImageReferences/ImageRef.cs +++ b/dotnet/ImageReferences/ImageRef.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences; /// diff --git a/dotnet/ImageReferences/PartialImageRef.Parsing.cs b/dotnet/ImageReferences/PartialImageRef.Parsing.cs index bfe9e2a..dd6a7af 100644 --- a/dotnet/ImageReferences/PartialImageRef.Parsing.cs +++ b/dotnet/ImageReferences/PartialImageRef.Parsing.cs @@ -1,5 +1,4 @@ using System.Text.RegularExpressions; -using HLabs.ImageReferences.Components; namespace HLabs.ImageReferences; diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 024c21b..8100b5b 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences; // TODO support platform @@ -264,6 +262,181 @@ public PartialImageRef With( Namespace? ns ) => public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); + // Additional fluent builder methods for better discoverability + + /// + /// Returns a new instance with a different tag. + /// Alias for for better fluent API discoverability. + /// + /// The tag to use, or null to remove the tag. + /// A new with the specified tag. + public PartialImageRef WithTag( Tag? tag ) => With( tag ); + + /// + /// Returns a new instance with a different tag. + /// + /// The tag to use as a string. + /// A new with the specified tag. + public PartialImageRef WithTag( string tag ) => With( new Tag( tag ) ); + + /// + /// Returns a new instance with a different registry. + /// Alias for for better fluent API discoverability. + /// + /// The registry to use, or null to remove the registry. + /// A new with the specified registry. + public PartialImageRef WithRegistry( Registry? registry ) => With( registry ); + + /// + /// Returns a new instance with a different registry. + /// + /// The registry hostname to use as a string. + /// A new with the specified registry. + public PartialImageRef WithRegistry( string registryHostname ) => With( new Registry( registryHostname ) ); + + /// + /// Returns a new instance with a different namespace. + /// Alias for for better fluent API discoverability. + /// + /// The namespace to use, or null to remove the namespace. + /// A new with the specified namespace. + public PartialImageRef WithNamespace( Namespace? ns ) => With( ns ); + + /// + /// Returns a new instance with a different namespace. + /// + /// The namespace to use as a string. + /// A new with the specified namespace. + public PartialImageRef WithNamespace( string ns ) => With( new Namespace( ns ) ); + + /// + /// Returns a new instance with a different digest. + /// Alias for for better fluent API discoverability. + /// + /// The digest to use, or null to remove the digest. + /// A new with the specified digest. + public PartialImageRef WithDigest( Digest? digest ) => With( digest ); + + /// + /// Returns a new instance with a different digest. + /// + /// The digest to use as a string. + /// A new with the specified digest. + public PartialImageRef WithDigest( string digest ) => With( new Digest( digest ) ); + + /// + /// Configures this image for a specific registry and namespace. + /// + /// The registry to use. + /// The namespace to use. + /// A new with the specified registry and namespace. + public PartialImageRef On( Registry registry, Namespace ns ) => With( registry, ns ); + + /// + /// Configures this image for a specific registry and namespace. + /// + /// The registry hostname to use as a string. + /// The namespace to use as a string. + /// A new with the specified registry and namespace. + public PartialImageRef On( string registryHostname, string ns ) => + With( new Registry( registryHostname ), new Namespace( ns ) ); + + /// + /// Configures this image for a specific registry. + /// + /// The registry to use. + /// A new with the specified registry. + public PartialImageRef On( Registry registry ) => With( registry ); + + /// + /// Configures this image for a specific registry. + /// + /// The registry hostname to use as a string. + /// A new with the specified registry. + public PartialImageRef On( string registryHostname ) => With( new Registry( registryHostname ) ); + + /// + /// Configures this image for a specific namespace. + /// + /// The namespace to use. + /// A new with the specified namespace. + public PartialImageRef In( Namespace ns ) => With( ns ); + + /// + /// Configures this image for a specific namespace. + /// + /// The namespace to use as a string. + /// A new with the specified namespace. + public PartialImageRef In( string ns ) => With( new Namespace( ns ) ); + + // Static factory methods as alternatives to constructors + + /// + /// Creates a partial image reference from a repository name. + /// Alternative to using constructors. + /// + /// The repository name. + /// A new . + public static PartialImageRef From( Repository repository ) => new(repository); + + /// + /// Creates a partial image reference from a repository name. + /// Alternative to using constructors. + /// + /// The repository name as a string. + /// A new . + public static PartialImageRef From( string repository ) => new(new Repository( repository )); + + /// + /// Creates a partial image reference from a repository and tag. + /// Alternative to using constructors. + /// + /// The repository name. + /// The tag. + /// A new . + public static PartialImageRef From( string repository, string tag ) => + new(new Repository( repository ), new Tag( tag )); + + /// + /// Creates a partial image reference for the localhost registry. + /// Convenient factory method for development scenarios. + /// + /// The repository name. + /// The tag (optional, defaults to "latest"). + /// A new configured for localhost. + public static PartialImageRef Localhost( string repository, string? tag = null ) => + From( repository ) + .WithRegistry( Registry.Localhost ) + .WithTag( tag ?? "latest" ); + + /// + /// Creates a partial image reference for GitHub Container Registry. + /// Convenient factory method for GitHub-hosted images. + /// + /// The GitHub organization or username. + /// The repository name. + /// The tag (optional, defaults to "latest"). + /// A new configured for GitHub Container Registry. + public static PartialImageRef GitHub( string @namespace, string repository, string? tag = null ) => + From( repository ) + .WithRegistry( Registry.GitHub ) + .WithNamespace( @namespace ) + .WithTag( tag ?? "latest" ); + + /// + /// Creates a partial image reference for Docker Hub. + /// Convenient factory method for Docker Hub-hosted images. + /// + /// The Docker Hub organization or username. + /// The repository name. + /// The tag (optional, defaults to "latest"). + /// A new configured for Docker Hub. + public static PartialImageRef DockerHub( string @namespace, string repository, string? tag = null ) => + From( repository ) + .WithRegistry( Registry.DockerHub ) + .WithNamespace( @namespace ) + .WithTag( tag ?? "latest" ); + /// /// Gets a value indicating whether this reference has all required components for qualification. /// diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs index 531e243..d01a4b3 100644 --- a/dotnet/ImageReferences/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences; // ----------------------------- From 641bebe045461a2c90130df4981e9339caefee63 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:04:41 +0100 Subject: [PATCH 18/36] f --- .../ImageReferences/Components/Registry.Instances.cs | 10 ++++++++++ dotnet/ImageReferences/Parsing/StringExtensions.cs | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/dotnet/ImageReferences/Components/Registry.Instances.cs b/dotnet/ImageReferences/Components/Registry.Instances.cs index 0ed2f88..52d300c 100644 --- a/dotnet/ImageReferences/Components/Registry.Instances.cs +++ b/dotnet/ImageReferences/Components/Registry.Instances.cs @@ -26,6 +26,11 @@ public sealed partial record Registry { /// /// Registry name. /// The custom ACR registry. + /// + /// + /// Acr("myregistry"); // myregistry.azurecr.io + /// + /// public static Registry Acr( string name ) => new($"{name}.azurecr.io"); /// @@ -34,6 +39,11 @@ public sealed partial record 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 ); diff --git a/dotnet/ImageReferences/Parsing/StringExtensions.cs b/dotnet/ImageReferences/Parsing/StringExtensions.cs index cd7fa51..066da33 100644 --- a/dotnet/ImageReferences/Parsing/StringExtensions.cs +++ b/dotnet/ImageReferences/Parsing/StringExtensions.cs @@ -20,7 +20,7 @@ public static class StringExtensions { /// /// public PartialImageRef Image() => PartialImageRef.Parse( imageReference ); -/* + /// /// Creates a qualified container image reference. /// @@ -31,6 +31,6 @@ public static class StringExtensions { /// Creates a pinned container image reference. That is, a reference that is guaranteed to resolve to the same image (content-addressable). /// This is only possible if the image reference has a digest. /// - public CanonicalImageRef CanonicalImage() => PartialImageRef.Parse( imageReference ).Canonicalize();*/ + public CanonicalImageRef CanonicalImage() => PartialImageRef.Parse( imageReference ).Canonicalize(); } } \ No newline at end of file From 9a92bb928c6d9efbc82013c23af0f0da592231b2 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:30:49 +0100 Subject: [PATCH 19/36] with --- dotnet/ImageReferences/CanonicalImageRef.cs | 23 ++- dotnet/ImageReferences/PartialImageRef.cs | 175 ------------------ .../PartialImageRefExtensions.cs | 60 +++++- dotnet/ImageReferences/QualifiedImageRef.cs | 32 +++- 4 files changed, 98 insertions(+), 192 deletions(-) diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs index c4ef38b..9597003 100644 --- a/dotnet/ImageReferences/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -37,7 +37,7 @@ internal CanonicalImageRef( /// /// The tag to apply to the new reference. /// A new with the specified tag. - public CanonicalImageRef WithTag( Tag tag ) => + public CanonicalImageRef With( Tag tag ) => new(Registry, Namespace, Repository, Digest, tag); /// @@ -45,17 +45,34 @@ public CanonicalImageRef WithTag( Tag tag ) => /// /// The registry to use for the new reference. /// A new with the specified registry. - public CanonicalImageRef On( Registry registry ) => + public CanonicalImageRef With( Registry registry ) => new(registry, Namespace, Repository, Digest, Tag); + /// + /// Returns a new instance with a different registry and namespace. + /// + /// The registry to use for the new reference. + /// The namespace to use for the new reference. + /// 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 a different namespace. /// /// The namespace to use for the new reference. /// A new with the specified namespace. - public CanonicalImageRef WithNamespace( Namespace ns ) => + public CanonicalImageRef With( Namespace ns ) => new(Registry, ns, Repository, Digest, Tag); + /// + /// Returns a new instance with a different digest. + /// + /// The digest to use. + /// A new with the specified digest. + public QualifiedImageRef With( Digest? digest ) => + new(Registry, Namespace, Repository, Tag, digest); + /// /// Gets a value indicating whether this reference has a fully qualified registry. /// Always true for canonical references as they always have a registry. diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 8100b5b..04ae90e 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -262,181 +262,6 @@ public PartialImageRef With( Namespace? ns ) => public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); - // Additional fluent builder methods for better discoverability - - /// - /// Returns a new instance with a different tag. - /// Alias for for better fluent API discoverability. - /// - /// The tag to use, or null to remove the tag. - /// A new with the specified tag. - public PartialImageRef WithTag( Tag? tag ) => With( tag ); - - /// - /// Returns a new instance with a different tag. - /// - /// The tag to use as a string. - /// A new with the specified tag. - public PartialImageRef WithTag( string tag ) => With( new Tag( tag ) ); - - /// - /// Returns a new instance with a different registry. - /// Alias for for better fluent API discoverability. - /// - /// The registry to use, or null to remove the registry. - /// A new with the specified registry. - public PartialImageRef WithRegistry( Registry? registry ) => With( registry ); - - /// - /// Returns a new instance with a different registry. - /// - /// The registry hostname to use as a string. - /// A new with the specified registry. - public PartialImageRef WithRegistry( string registryHostname ) => With( new Registry( registryHostname ) ); - - /// - /// Returns a new instance with a different namespace. - /// Alias for for better fluent API discoverability. - /// - /// The namespace to use, or null to remove the namespace. - /// A new with the specified namespace. - public PartialImageRef WithNamespace( Namespace? ns ) => With( ns ); - - /// - /// Returns a new instance with a different namespace. - /// - /// The namespace to use as a string. - /// A new with the specified namespace. - public PartialImageRef WithNamespace( string ns ) => With( new Namespace( ns ) ); - - /// - /// Returns a new instance with a different digest. - /// Alias for for better fluent API discoverability. - /// - /// The digest to use, or null to remove the digest. - /// A new with the specified digest. - public PartialImageRef WithDigest( Digest? digest ) => With( digest ); - - /// - /// Returns a new instance with a different digest. - /// - /// The digest to use as a string. - /// A new with the specified digest. - public PartialImageRef WithDigest( string digest ) => With( new Digest( digest ) ); - - /// - /// Configures this image for a specific registry and namespace. - /// - /// The registry to use. - /// The namespace to use. - /// A new with the specified registry and namespace. - public PartialImageRef On( Registry registry, Namespace ns ) => With( registry, ns ); - - /// - /// Configures this image for a specific registry and namespace. - /// - /// The registry hostname to use as a string. - /// The namespace to use as a string. - /// A new with the specified registry and namespace. - public PartialImageRef On( string registryHostname, string ns ) => - With( new Registry( registryHostname ), new Namespace( ns ) ); - - /// - /// Configures this image for a specific registry. - /// - /// The registry to use. - /// A new with the specified registry. - public PartialImageRef On( Registry registry ) => With( registry ); - - /// - /// Configures this image for a specific registry. - /// - /// The registry hostname to use as a string. - /// A new with the specified registry. - public PartialImageRef On( string registryHostname ) => With( new Registry( registryHostname ) ); - - /// - /// Configures this image for a specific namespace. - /// - /// The namespace to use. - /// A new with the specified namespace. - public PartialImageRef In( Namespace ns ) => With( ns ); - - /// - /// Configures this image for a specific namespace. - /// - /// The namespace to use as a string. - /// A new with the specified namespace. - public PartialImageRef In( string ns ) => With( new Namespace( ns ) ); - - // Static factory methods as alternatives to constructors - - /// - /// Creates a partial image reference from a repository name. - /// Alternative to using constructors. - /// - /// The repository name. - /// A new . - public static PartialImageRef From( Repository repository ) => new(repository); - - /// - /// Creates a partial image reference from a repository name. - /// Alternative to using constructors. - /// - /// The repository name as a string. - /// A new . - public static PartialImageRef From( string repository ) => new(new Repository( repository )); - - /// - /// Creates a partial image reference from a repository and tag. - /// Alternative to using constructors. - /// - /// The repository name. - /// The tag. - /// A new . - public static PartialImageRef From( string repository, string tag ) => - new(new Repository( repository ), new Tag( tag )); - - /// - /// Creates a partial image reference for the localhost registry. - /// Convenient factory method for development scenarios. - /// - /// The repository name. - /// The tag (optional, defaults to "latest"). - /// A new configured for localhost. - public static PartialImageRef Localhost( string repository, string? tag = null ) => - From( repository ) - .WithRegistry( Registry.Localhost ) - .WithTag( tag ?? "latest" ); - - /// - /// Creates a partial image reference for GitHub Container Registry. - /// Convenient factory method for GitHub-hosted images. - /// - /// The GitHub organization or username. - /// The repository name. - /// The tag (optional, defaults to "latest"). - /// A new configured for GitHub Container Registry. - public static PartialImageRef GitHub( string @namespace, string repository, string? tag = null ) => - From( repository ) - .WithRegistry( Registry.GitHub ) - .WithNamespace( @namespace ) - .WithTag( tag ?? "latest" ); - - /// - /// Creates a partial image reference for Docker Hub. - /// Convenient factory method for Docker Hub-hosted images. - /// - /// The Docker Hub organization or username. - /// The repository name. - /// The tag (optional, defaults to "latest"). - /// A new configured for Docker Hub. - public static PartialImageRef DockerHub( string @namespace, string repository, string? tag = null ) => - From( repository ) - .WithRegistry( Registry.DockerHub ) - .WithNamespace( @namespace ) - .WithTag( tag ?? "latest" ); - /// /// Gets a value indicating whether this reference has all required components for qualification. /// diff --git a/dotnet/ImageReferences/PartialImageRefExtensions.cs b/dotnet/ImageReferences/PartialImageRefExtensions.cs index 9d75e45..dc58935 100644 --- a/dotnet/ImageReferences/PartialImageRefExtensions.cs +++ b/dotnet/ImageReferences/PartialImageRefExtensions.cs @@ -1,5 +1,3 @@ -using HLabs.ImageReferences.Components; - namespace HLabs.ImageReferences; /// @@ -9,6 +7,8 @@ public static class PartialImageRefExtensions { #pragma warning disable CA1034 extension( PartialImageRef imageRef ) { #pragma warning restore CA1034 + // Single component overloads - most common use cases + /// /// Qualifies the image reference with a different tag, filling in defaults for missing components. /// @@ -25,8 +25,11 @@ public QualifiedImageRef Qualify( Tag? tag ) => public QualifiedImageRef Qualify( Registry? registry ) => imageRef.With( registry ).Qualify(); + // Multi-component overloads - practical combinations + /// /// Qualifies the image reference with a different registry and namespace, filling in defaults for missing components. + /// Common pattern for switching to organization registries like ghcr.io/myorg or registry.io/myorg. /// /// The registry to use. /// The namespace to use. @@ -35,19 +38,56 @@ public QualifiedImageRef Qualify( Registry registry, Namespace ns ) => imageRef.With( registry, ns ).Qualify(); /// - /// Qualifies the image reference with a different namespace, filling in defaults for missing components. + /// Qualifies the image reference with tag and registry, filling in defaults for missing components. + /// Common pattern for versioning images across different registries. /// - /// The namespace to use. + /// The tag to use. + /// The registry to use. /// A qualified image reference. - public QualifiedImageRef Qualify( Namespace? ns ) => - imageRef.With( ns ).Qualify(); + public QualifiedImageRef Qualify( Tag tag, Registry registry ) => + imageRef.With( tag ).With( registry ).Qualify(); /// - /// Qualifies the image reference with a different digest, filling in defaults for missing components. + /// Qualifies the image reference with tag, registry, and namespace, filling in defaults for missing components. + /// Complete specification for publishing versioned images to organization registries. /// - /// The digest to use. + /// The tag to use. + /// The registry to use. + /// The namespace to use. /// A qualified image reference. - public QualifiedImageRef Qualify( Digest? digest ) => - imageRef.With( digest ).Qualify(); + public QualifiedImageRef Qualify( Tag tag, Registry registry, Namespace ns ) => + imageRef.With( tag ).With( registry ).With( ns ).Qualify(); + + // Canonicalization overloads - digest is required + + /// + /// Qualifies and canonicalizes the image reference with a digest, creating an immutable reference. + /// More terse than .With(digest).Qualify().Canonicalize(). + /// + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Digest digest ) => + imageRef.With( digest ).Qualify().Canonicalize(); + + /// + /// Qualifies and canonicalizes the image reference with registry and digest, creating an immutable reference. + /// Useful for pinning images from a specific registry. + /// + /// The registry to use. + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Registry registry, Digest digest ) => + imageRef.With( registry ).With( digest ).Qualify().Canonicalize(); + + /// + /// Qualifies and canonicalizes the image reference with registry, namespace, and digest, creating an immutable reference. + /// Useful for pinning images from organization registries. + /// + /// The registry to use. + /// The namespace to use. + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Registry registry, Namespace ns, Digest digest ) => + imageRef.With( registry ).With( ns ).With( digest ).Qualify().Canonicalize(); } } \ No newline at end of file diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs index d01a4b3..428e48e 100644 --- a/dotnet/ImageReferences/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -35,17 +35,41 @@ internal QualifiedImageRef( Registry registry, Namespace? ns, Repository reposit /// /// The tag to use. /// A new with the specified tag. - public QualifiedImageRef WithTag( Tag tag ) => + public QualifiedImageRef With( Tag? tag ) => new(Registry, Namespace, Repository, tag, Digest); /// /// Returns a new instance with a different registry. /// /// The registry to use. - /// Optional namespace to use; if not provided, uses the current namespace. + /// A new with the specified registry. + public QualifiedImageRef With( Registry registry ) => + new(registry, Namespace, Repository, Tag, Digest); + + /// + /// Returns a new instance with a different registry and namespace. + /// + /// The registry to use. + /// The namespace to use. /// A new with the specified registry and namespace. - public QualifiedImageRef On( Registry registry, Namespace? ns = null ) => - new(registry, ns ?? Namespace, Repository, Tag, Digest); + public QualifiedImageRef With( Registry registry, Namespace ns ) => + new(registry, ns, Repository, Tag, Digest); + + /// + /// Returns a new instance with a different namespace. + /// + /// The namespace to use. + /// A new with the specified namespace. + public QualifiedImageRef With( Namespace? ns ) => + new(Registry, ns, Repository, Tag, Digest); + + /// + /// Returns a new instance with a different digest. + /// + /// The digest to use. + /// A new with the specified digest. + public QualifiedImageRef With( Digest? digest ) => + new(Registry, Namespace, Repository, Tag, digest); /// /// Creates a digest-pinned image reference. From 15d5cc40b233ce429fd41931b14477a1cbd39e92 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:37:04 +0100 Subject: [PATCH 20/36] f --- .../PartialImageRefExtensions.cs | 0 .../Extensions/QualifiedImageRefExtensions.cs | 40 +++++++++++++++++++ 2 files changed, 40 insertions(+) rename dotnet/ImageReferences/{ => Extensions}/PartialImageRefExtensions.cs (100%) create mode 100644 dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs diff --git a/dotnet/ImageReferences/PartialImageRefExtensions.cs b/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs similarity index 100% rename from dotnet/ImageReferences/PartialImageRefExtensions.cs rename to dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs diff --git a/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs b/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs new file mode 100644 index 0000000..f70e00a --- /dev/null +++ b/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs @@ -0,0 +1,40 @@ +namespace HLabs.ImageReferences; + +/// +/// Extension methods for that provide convenient canonicalization with different components. +/// +public static class QualifiedImageRefExtensions { +#pragma warning disable CA1034 + extension( QualifiedImageRef imageRef ) { +#pragma warning restore CA1034 + /// + /// Canonicalizes the image reference with a digest, creating an immutable reference. + /// More terse than .With(digest).Canonicalize(). + /// + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Digest digest ) => + imageRef.With( digest ).Canonicalize(); + + /// + /// Canonicalizes the image reference with registry and digest, creating an immutable reference. + /// Useful for pinning images from a specific registry. + /// + /// The registry to use. + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Registry registry, Digest digest ) => + imageRef.With( registry ).With( digest ).Canonicalize(); + + /// + /// Canonicalizes the image reference with registry, namespace, and digest, creating an immutable reference. + /// Useful for pinning images from organization registries. + /// + /// The registry to use. + /// The namespace to use. + /// The digest to pin the reference with. + /// A canonical (immutable) image reference. + public CanonicalImageRef Canonicalize( Registry registry, Namespace ns, Digest digest ) => + imageRef.With( registry, ns ).With( digest ).Canonicalize(); + } +} \ No newline at end of file From 475b6cbd0e9455a1dd30bde7b78fbae8ee26c18a Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 21:44:42 +0100 Subject: [PATCH 21/36] align docs --- dotnet/ImageReferences/CanonicalImageRef.cs | 36 ++++---- .../Extensions/PartialImageRefExtensions.cs | 84 +++++++++---------- .../Extensions/QualifiedImageRefExtensions.cs | 29 +++---- dotnet/ImageReferences/PartialImageRef.cs | 22 ++--- dotnet/ImageReferences/QualifiedImageRef.cs | 22 ++--- 5 files changed, 92 insertions(+), 101 deletions(-) diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs index 9597003..891402d 100644 --- a/dotnet/ImageReferences/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -33,46 +33,46 @@ internal CanonicalImageRef( } /// - /// Returns a new instance with a different cosmetic tag. + /// Returns a new instance with the specified tag. /// - /// The tag to apply to the new reference. + /// The tag. /// A new with the specified tag. - public CanonicalImageRef With( Tag tag ) => + public CanonicalImageRef With( Tag? tag ) => new(Registry, Namespace, Repository, Digest, tag); /// - /// Returns a new instance with a different registry. + /// Returns a new instance with the specified digest. /// - /// The registry to use for the new reference. + /// 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 a different registry and namespace. + /// Returns a new instance with the specified registry and namespace. /// - /// The registry to use for the new reference. - /// The namespace to use for the new reference. + /// 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 a different namespace. + /// Returns a new instance with the specified namespace. /// - /// The namespace to use for the new reference. + /// The namespace. /// A new with the specified namespace. public CanonicalImageRef With( Namespace ns ) => new(Registry, ns, Repository, Digest, Tag); - /// - /// Returns a new instance with a different digest. - /// - /// The digest to use. - /// A new with the specified digest. - public QualifiedImageRef With( Digest? digest ) => - new(Registry, Namespace, Repository, Tag, digest); - /// /// Gets a value indicating whether this reference has a fully qualified registry. /// Always true for canonical references as they always have a registry. diff --git a/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs b/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs index dc58935..ed3d813 100644 --- a/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs +++ b/dotnet/ImageReferences/Extensions/PartialImageRefExtensions.cs @@ -1,93 +1,87 @@ namespace HLabs.ImageReferences; /// -/// Extension methods for that provide convenient qualification with different components. +/// 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 - most common use cases + // Single component overloads /// - /// Qualifies the image reference with a different tag, filling in defaults for missing components. + /// Qualifies the image reference with the specified registry. /// - /// The tag to use. + /// The registry. /// A qualified image reference. - public QualifiedImageRef Qualify( Tag? tag ) => - imageRef.With( tag ).Qualify(); + public QualifiedImageRef Qualify( Registry? registry ) => + imageRef.With( registry ).Qualify(); /// - /// Qualifies the image reference with a different registry, filling in defaults for missing components. + /// Qualifies the image reference with the specified tag. /// - /// The registry to use. + /// The tag. /// A qualified image reference. - public QualifiedImageRef Qualify( Registry? registry ) => - imageRef.With( registry ).Qualify(); + public QualifiedImageRef Qualify( Tag? tag ) => + imageRef.With( tag ).Qualify(); - // Multi-component overloads - practical combinations + // Multi-component overloads /// - /// Qualifies the image reference with a different registry and namespace, filling in defaults for missing components. - /// Common pattern for switching to organization registries like ghcr.io/myorg or registry.io/myorg. + /// Qualifies the image reference with the specified registry and namespace. /// - /// The registry to use. - /// The namespace to use. + /// 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 tag and registry, filling in defaults for missing components. - /// Common pattern for versioning images across different registries. + /// Qualifies the image reference with the specified registry and tag. /// - /// The tag to use. - /// The registry to use. + /// The registry. + /// The tag. /// A qualified image reference. - public QualifiedImageRef Qualify( Tag tag, Registry registry ) => - imageRef.With( tag ).With( registry ).Qualify(); + public QualifiedImageRef Qualify( Registry registry, Tag tag ) => + imageRef.With( registry ).With( tag ).Qualify(); /// - /// Qualifies the image reference with tag, registry, and namespace, filling in defaults for missing components. - /// Complete specification for publishing versioned images to organization registries. + /// Qualifies the image reference with the specified registry, namespace, and tag. /// - /// The tag to use. - /// The registry to use. - /// The namespace to use. + /// The registry. + /// The namespace. + /// The tag. /// A qualified image reference. - public QualifiedImageRef Qualify( Tag tag, Registry registry, Namespace ns ) => - imageRef.With( tag ).With( registry ).With( ns ).Qualify(); + public QualifiedImageRef Qualify( Registry registry, Namespace ns, Tag tag ) => + imageRef.With( registry, ns ).With( tag ).Qualify(); - // Canonicalization overloads - digest is required + // Canonicalization overloads /// - /// Qualifies and canonicalizes the image reference with a digest, creating an immutable reference. - /// More terse than .With(digest).Qualify().Canonicalize(). + /// Canonicalizes the image reference with the specified digest. /// - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// The digest. + /// A canonical image reference. public CanonicalImageRef Canonicalize( Digest digest ) => imageRef.With( digest ).Qualify().Canonicalize(); /// - /// Qualifies and canonicalizes the image reference with registry and digest, creating an immutable reference. - /// Useful for pinning images from a specific registry. + /// Canonicalizes the image reference with the specified registry and digest. /// - /// The registry to use. - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// The registry. + /// The digest. + /// A canonical image reference. public CanonicalImageRef Canonicalize( Registry registry, Digest digest ) => imageRef.With( registry ).With( digest ).Qualify().Canonicalize(); /// - /// Qualifies and canonicalizes the image reference with registry, namespace, and digest, creating an immutable reference. - /// Useful for pinning images from organization registries. + /// Canonicalizes the image reference with the specified registry, namespace, and digest. /// - /// The registry to use. - /// The namespace to use. - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// The registry. + /// The namespace. + /// The digest. + /// A canonical image reference. public CanonicalImageRef Canonicalize( Registry registry, Namespace ns, Digest digest ) => - imageRef.With( registry ).With( ns ).With( digest ).Qualify().Canonicalize(); + 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 index f70e00a..5d480c4 100644 --- a/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs +++ b/dotnet/ImageReferences/Extensions/QualifiedImageRefExtensions.cs @@ -1,39 +1,36 @@ namespace HLabs.ImageReferences; /// -/// Extension methods for that provide convenient canonicalization with different components. +/// 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 a digest, creating an immutable reference. - /// More terse than .With(digest).Canonicalize(). + /// Canonicalizes the image reference with the specified digest. /// - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// The digest. + /// A canonical image reference. public CanonicalImageRef Canonicalize( Digest digest ) => imageRef.With( digest ).Canonicalize(); /// - /// Canonicalizes the image reference with registry and digest, creating an immutable reference. - /// Useful for pinning images from a specific registry. + /// Canonicalizes the image reference with the specified registry and digest. /// - /// The registry to use. - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// 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 registry, namespace, and digest, creating an immutable reference. - /// Useful for pinning images from organization registries. + /// Canonicalizes the image reference with the specified registry, namespace, and digest. /// - /// The registry to use. - /// The namespace to use. - /// The digest to pin the reference with. - /// A canonical (immutable) image reference. + /// 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(); } diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 04ae90e..4b99db2 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -221,43 +221,43 @@ public bool TryQualify( out QualifiedImageRef? canonical, out string? reason ) { } /// - /// Returns a new instance with a different cosmetic tag. + /// Returns a new instance with the specified tag. /// - /// The tag to use, or null to remove the tag. + /// The tag, or null to remove it. /// A new with the specified tag. public PartialImageRef With( Tag? tag ) => new(Repository, tag, Registry, Namespace, Digest); /// - /// Returns a new instance with a different registry. + /// Returns a new instance with the specified registry. /// - /// The registry to use, or null to remove the registry. + /// The registry, or null to remove it. /// A new with the specified registry. public PartialImageRef With( Registry? registry ) => new(Repository, Tag, registry, Namespace, Digest); /// - /// Returns a new instance with a different registry. + /// Returns a new instance with the specified registry and namespace. /// - /// The registry to use. - /// The namespace to use. + /// 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 a different namespace. + /// Returns a new instance with the specified namespace. /// - /// The namespace to use, or null to remove the namespace. + /// The namespace, or null to remove it. /// A new with the specified namespace. public PartialImageRef With( Namespace? ns ) => // TODO remember registry/namespace requirement new(Repository, Tag, Registry, ns, Digest); /// - /// Returns a new instance with a different digest. + /// Returns a new instance with the specified digest. /// - /// The digest to use, or null to remove the digest. + /// The digest, or null to remove it. /// A new with the specified digest. public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs index 428e48e..af3a1ce 100644 --- a/dotnet/ImageReferences/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -31,42 +31,42 @@ internal QualifiedImageRef( Registry registry, Namespace? ns, Repository reposit } /// - /// Returns a new instance with a different tag. + /// Returns a new instance with the specified tag. /// - /// The tag to use. + /// The tag, or null to remove it. /// A new with the specified tag. public QualifiedImageRef With( Tag? tag ) => new(Registry, Namespace, Repository, tag, Digest); /// - /// Returns a new instance with a different registry. + /// Returns a new instance with the specified registry. /// - /// The registry to use. + /// The registry. /// A new with the specified registry. public QualifiedImageRef With( Registry registry ) => new(registry, Namespace, Repository, Tag, Digest); /// - /// Returns a new instance with a different registry and namespace. + /// Returns a new instance with the specified registry and namespace. /// - /// The registry to use. - /// The namespace to use. + /// 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 a different namespace. + /// Returns a new instance with the specified namespace. /// - /// The namespace to use. + /// The namespace, or null to remove it. /// A new with the specified namespace. public QualifiedImageRef With( Namespace? ns ) => new(Registry, ns, Repository, Tag, Digest); /// - /// Returns a new instance with a different digest. + /// Returns a new instance with the specified digest. /// - /// The digest to use. + /// The digest, or null to remove it. /// A new with the specified digest. public QualifiedImageRef With( Digest? digest ) => new(Registry, Namespace, Repository, Tag, digest); From 5a998fd3d811245ec7e3715b901098591bb2c622 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:09:14 +0100 Subject: [PATCH 22/36] docs --- dotnet/ImageReferences/CanonicalImageRef.cs | 26 +++---- dotnet/ImageReferences/ImageRef.cs | 30 ++------ dotnet/ImageReferences/PartialImageRef.cs | 77 +++++++++------------ dotnet/ImageReferences/QualifiedImageRef.cs | 19 ++--- 4 files changed, 61 insertions(+), 91 deletions(-) diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs index 891402d..9bdbe82 100644 --- a/dotnet/ImageReferences/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -12,6 +12,13 @@ public sealed record CanonicalImageRef : ImageRef { get; } + /// + /// Gets the repository. + /// + public new Repository Repository { + get; + } + /// /// Gets the digest. /// @@ -25,11 +32,12 @@ internal CanonicalImageRef( Repository repository, Digest digest, Tag? tag = null - ) : base( repository ) { + ) { 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; // optional, does not affect immutability + Tag = tag; // Cosmetic } /// @@ -73,18 +81,6 @@ public CanonicalImageRef With( Registry registry, Namespace ns ) => public CanonicalImageRef With( Namespace ns ) => new(Registry, ns, Repository, Digest, Tag); - /// - /// Gets a value indicating whether this reference has a fully qualified registry. - /// Always true for canonical references as they always have a registry. - /// - public override bool IsQualified => true; - - /// - /// Gets a value indicating whether this reference is pinned by a digest. - /// Always true for canonical references as they always have a digest. - /// - public override bool IsPinned => true; - /// /// Returns a string representation of this canonical image reference, /// including the registry, namespace (if present), repository, digest, and tag (if present). @@ -93,7 +89,7 @@ public CanonicalImageRef With( Namespace ns ) => public override string ToString() { var nsPart = Namespace is not null ? $"{Namespace}/" : string.Empty; return Tag is not null - ? $"{Registry}/{nsPart}{Repository}:{Tag}@{Digest}" // shows tag + digest + ? $"{Registry}/{nsPart}{Repository}:{Tag}@{Digest}" : $"{Registry}/{nsPart}{Repository}@{Digest}"; } } \ No newline at end of file diff --git a/dotnet/ImageReferences/ImageRef.cs b/dotnet/ImageReferences/ImageRef.cs index ff46c67..43a73f8 100644 --- a/dotnet/ImageReferences/ImageRef.cs +++ b/dotnet/ImageReferences/ImageRef.cs @@ -4,15 +4,6 @@ namespace HLabs.ImageReferences; /// Base class for container image references, providing common properties. /// public abstract record ImageRef { - /// - /// Initializes a new instance of the class. - /// - /// The repository name. Cannot be null. - /// Thrown when repository is null. - private protected ImageRef( Repository repository ) { - Repository = repository ?? throw new ArgumentNullException( nameof(repository) ); - } - /// /// Gets the registry where the image is hosted (e.g., docker.io, ghcr.io). /// May be null for partial references. @@ -24,7 +15,7 @@ public Registry? Registry { #pragma warning disable CA1716 /// - /// Gets the namespace within the registry (e.g., organization or user name). + /// 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 { @@ -35,14 +26,16 @@ public Namespace? Namespace { /// /// Gets the repository name (e.g., nginx, ubuntu, myapp). + /// May be null for partial references. /// - public Repository Repository { + 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 digest-only references. + /// May be null for partial references or qualified references with a digest. /// public Tag? Tag { get; @@ -51,21 +44,10 @@ public Tag? Tag { /// /// Gets the content-addressable digest that uniquely identifies image content. - /// May be null for tag-only references. + /// May be null for partial references or qualified references with a tag. /// public Digest? Digest { get; protected init; } - - /// - /// Gets a value indicating whether this reference is pinned by a . - /// - public virtual bool IsPinned => Digest is not null; - - /// - /// Gets a value indicating whether this reference can identify a specific image (has at least a tag or digest). - /// Note: a tag-based canonical reference is not pinned — the tag can be moved to a different image. - /// - public abstract bool IsQualified { get; } } \ No newline at end of file diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 4b99db2..6903962 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -15,14 +15,15 @@ namespace HLabs.ImageReferences; /// public sealed partial record PartialImageRef : ImageRef { private PartialImageRef( - Repository repository, + Repository? repository, #pragma warning disable S3427 Tag? tag = null, #pragma warning restore S3427 Registry? registry = null, Namespace? @namespace = null, Digest? digest = null - ) : base( repository ) { + ) { + Repository = repository; Tag = tag; Registry = registry; Namespace = @namespace; @@ -126,7 +127,7 @@ public PartialImageRef( Registry registry, Repository repository, Tag tag ) /// The registry. /// The repository name. /// The digest. - public PartialImageRef( Registry registry, Repository repository, Digest digest ) + public PartialImageRef( Registry registry, Repository? repository, Digest digest ) : this( repository, null, registry, null, digest ) { } @@ -187,38 +188,6 @@ public PartialImageRef( Registry registry, Namespace ns, Repository repository, #endregion - /// - /// Returns a string representation of this image reference. - /// - /// A string representation of this image reference. - public override string ToString() { - var reg = Registry is null ? string.Empty : $"{Registry}/"; - var ns = Namespace is null ? string.Empty : $"{Namespace}/"; - var tag = Tag is null ? string.Empty : $":{Tag}"; - var digest = Digest is null ? string.Empty : $"@{Digest}"; - return $"{reg}{ns}{Repository}{tag}{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? canonical, out string? reason ) { - try { - canonical = Qualify(); - reason = null; - return true; - } -#pragma warning disable CA1031 - catch ( Exception ex ) { -#pragma warning restore CA1031 - canonical = null; - reason = ex.Message; - return false; - } - } /// /// Returns a new instance with the specified tag. @@ -263,15 +232,25 @@ public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); /// - /// Gets a value indicating whether this reference has all required components for qualification. - /// - public override bool IsQualified => Registry != null && ( !Registry.NamespaceRequired || Namespace != null ) && - ( Tag != null || Digest != null ); - - /// - /// Gets a value indicating whether this reference can be qualified using default conventions. + /// Tries to convert this partial reference to a qualified reference by applying default conventions. /// - public bool CanQualify => TryQualify( out _, out _ ); + /// 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? canonical, out string? reason ) { + try { + canonical = Qualify(); + reason = null; + return true; + } +#pragma warning disable CA1031 + catch ( Exception ex ) { +#pragma warning restore CA1031 + canonical = null; + reason = ex.Message; + return false; + } + } /// /// Converts this reference to a canonical form. @@ -323,4 +302,16 @@ public CanonicalImageRef Canonicalize( ) { return With( digest ).Canonicalize( canonicalizationMode, qualificationMode ); } + + /// + /// Returns a string representation of this image reference. + /// + /// A string representation of this image reference. + public override string ToString() { + var reg = Registry is null ? string.Empty : $"{Registry}/"; + var ns = Namespace is null ? string.Empty : $"{Namespace}/"; + var tag = Tag is null ? string.Empty : $":{Tag}"; + var digest = Digest is null ? string.Empty : $"@{Digest}"; + return $"{reg}{ns}{Repository}{tag}{digest}"; + } } \ No newline at end of file diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs index af3a1ce..7af74ab 100644 --- a/dotnet/ImageReferences/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -11,16 +11,23 @@ namespace HLabs.ImageReferences; /// public sealed record QualifiedImageRef : ImageRef { /// - /// Gets the registry for this image reference. + /// Gets the registry. /// public new Registry Registry { get; } - internal QualifiedImageRef( Registry registry, Namespace? ns, Repository repository, Tag? tag, Digest? digest ) : - base( repository ) { + /// + /// 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." ); @@ -101,12 +108,6 @@ public CanonicalImageRef Canonicalize( CanonicalizationMode mode = Canonicalizat return new CanonicalImageRef( Registry, Namespace, Repository, Digest, tag ); } - /// - /// Gets a value indicating whether this reference has a fully qualified registry. - /// Always true for qualified references as they always have a registry. - /// - public override bool IsQualified => true; - /// /// Returns a string representation of this qualified image reference. /// From d5f0505f848fd43f193cdf90a43396d338661cda Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:12:04 +0100 Subject: [PATCH 23/36] align tests --- .../CanonicalImageRefTests.cs | 66 ++++----------- .../PartialImageReferenceTests.cs | 84 ------------------- .../QualifiedImageRefTests.cs | 43 ++-------- 3 files changed, 20 insertions(+), 173 deletions(-) diff --git a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs index 2b98de6..7355a8f 100644 --- a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs @@ -51,31 +51,6 @@ public async Task ConstructWithOptionalTag() { await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); } - // ----------------------- - // IsQualified - // ----------------------- - [Test] - public async Task IsQualifiedAlwaysTrue() { - var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - await Assert.That( canonical.IsQualified ).IsTrue(); - } - - // ----------------------- - // IsPinned - // ----------------------- - [Test] - public async Task IsPinnedAlwaysTrue() { - var canonical = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - await Assert.That( canonical.IsPinned ).IsTrue(); - } - - [Test] - public async Task IsPinnedTrueEvenWithTag() { - var canonical = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) - .Canonicalize(); - await Assert.That( canonical.IsPinned ).IsTrue(); - } - // ----------------------- // ToString // ----------------------- @@ -130,7 +105,7 @@ public async Task ToStringShowsTagWhenPresent() { [Test] public async Task WithTagAddsTag() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.WithTag( Tag.Latest ); + 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}" ); @@ -139,7 +114,7 @@ public async Task WithTagAddsTag() { [Test] public async Task WithTagReplacesExistingTag() { var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.WithTag( new Tag( "alpine" ) ); + 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}" ); @@ -148,16 +123,15 @@ public async Task WithTagReplacesExistingTag() { [Test] public async Task WithTagPreservesDigest() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.WithTag( Tag.Latest ); + var updated = original.With( Tag.Latest ); await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( updated.IsPinned ).IsTrue(); } [Test] public async Task WithTagDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - _ = original.WithTag( Tag.Latest ); + _ = original.With( Tag.Latest ); await Assert.That( original.Tag ).IsNull(); } @@ -168,7 +142,7 @@ public async Task WithTagDoesNotMutateOriginal() { [Test] public async Task OnChangesRegistry() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.On( Registry.GitHub ); + 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}" ); @@ -177,16 +151,15 @@ public async Task OnChangesRegistry() { [Test] public async Task OnPreservesDigest() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.On( Registry.Localhost ); + var updated = original.With( Registry.Localhost ); await Assert.That( updated.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( updated.IsPinned ).IsTrue(); } [Test] public async Task OnDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - _ = original.On( Registry.GitHub ); + _ = original.With( Registry.GitHub ); await Assert.That( original.Registry ).IsEqualTo( Registry.DockerHub ); } @@ -197,7 +170,7 @@ public async Task OnDoesNotMutateOriginal() { [Test] public async Task WithNamespaceChangesNamespace() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.WithNamespace( new Namespace( "myorg" ) ); + 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}" ); @@ -207,7 +180,7 @@ public async Task WithNamespaceChangesNamespace() { public async Task WithNamespacePreservesOtherProperties() { var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ) .Canonicalize( CanonicalizationMode.MaintainTag ); - var updated = original.WithNamespace( new Namespace( "custom" ) ); + var updated = original.With( new Namespace( "custom" ) ); await Assert.That( updated.Registry ).IsEqualTo( Registry.DockerHub ); await Assert.That( updated.Repository ).IsEqualTo( new Repository( "nginx" ) ); @@ -218,7 +191,7 @@ public async Task WithNamespacePreservesOtherProperties() { [Test] public async Task WithNamespaceDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - _ = original.WithNamespace( new Namespace( "myorg" ) ); + _ = original.With( new Namespace( "myorg" ) ); await Assert.That( original.Namespace ).IsEqualTo( new Namespace( "library" ) ); } @@ -229,26 +202,15 @@ public async Task WithNamespaceDoesNotMutateOriginal() { [Test] public async Task DigestNeverChanges() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var withTag = original.WithTag( Tag.Latest ); - var withRegistry = original.On( Registry.GitHub ); - var withNamespace = original.WithNamespace( new Namespace( "myorg" ) ); + 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 ) ); } - [Test] - public async Task AllVariantsAreImmutable() { - var ref1 = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var ref2 = ref1.WithTag( Tag.Latest ); - var ref3 = ref2.On( Registry.GitHub ); - - await Assert.That( ref1.IsPinned ).IsTrue(); - await Assert.That( ref2.IsPinned ).IsTrue(); - await Assert.That( ref3.IsPinned ).IsTrue(); - } - // ----------------------- // Equality // ----------------------- @@ -313,7 +275,7 @@ public async Task SameDigestDifferentTagAreNotEqualWhenMaintained() { [Test] public async Task WithTagCreatesNewInstance() { var original = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Canonicalize(); - var updated = original.WithTag( Tag.Latest ); + var updated = original.With( Tag.Latest ); await Assert.That( ReferenceEquals( original, updated ) ).IsFalse(); } diff --git a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs index 9d4bda6..0c6f6de 100644 --- a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs +++ b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs @@ -193,90 +193,6 @@ public async Task ParseWithoutTagLeavesTagNull() { await Assert.That( image.Tag ).IsNull(); } - // ----------------------- - // IsCanonical - // ----------------------- - [Test] - public async Task IsCanonicalFalseWhenNoTagAndNoDigest() { - var image = new PartialImageRef( "nginx" ); - await Assert.That( image.IsQualified ).IsFalse(); - } - - [Test] - public async Task IsCanonicalTrueWhenTagPresent() { - var image = new PartialImageRef( "nginx", Tag.Latest ); - await Assert.That( image.IsQualified ).IsFalse(); - await Assert.That( image.CanQualify ).IsTrue(); - - var qualified = image.Qualify(); - await Assert.That( qualified.IsQualified ).IsTrue(); - await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); - // await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); - await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); - await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); - await Assert.That( qualified.ToString() ).IsEqualTo( "docker.io/library/nginx:latest" ); - } - - [Test] - public async Task IsCanonicalTrueWhenDigestPresent() { - var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ); - await Assert.That( image.IsQualified ).IsFalse(); - await Assert.That( image.CanQualify ).IsTrue(); - - var qualified = image.Qualify(); - await Assert.That( qualified.IsQualified ).IsTrue(); - // await Assert.That( canonical.Tag ).IsEqualTo( Tag.Latest ); - await Assert.That( qualified.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); - await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); - await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); - await Assert.That( qualified.ToString() ).IsEqualTo( $"docker.io/library/nginx@{ValidDigest}" ); - } - - [Test] - public async Task IsCanonicalTrueWhenBothTagAndDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ); - await Assert.That( image.IsQualified ).IsFalse(); - await Assert.That( image.CanQualify ).IsTrue(); - - var qualified = image.Qualify(); - await Assert.That( qualified.IsQualified ).IsTrue(); - await Assert.That( qualified.Tag ).IsEqualTo( Tag.Latest ); - await Assert.That( qualified.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( qualified.Registry ).IsEqualTo( Registry.DockerHub ); - await Assert.That( qualified.Repository ).IsEqualTo( new Repository( "nginx" ) ); - await Assert.That( qualified.Namespace ).IsEqualTo( new Namespace( "library" ) ); - await Assert.That( qualified.ToString() ).IsEqualTo( $"docker.io/library/nginx:latest@{ValidDigest}" ); - } - - // ----------------------- - // IsPinned - // ----------------------- - [Test] - public async Task IsImmutableFalseWhenNoDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest ); - await Assert.That( image.IsPinned ).IsFalse(); - } - - [Test] - public async Task IsImmutableFalseWhenNoTagAndNoDigest() { - var image = new PartialImageRef( "nginx" ); - await Assert.That( image.IsPinned ).IsFalse(); - } - - [Test] - public async Task IsImmutableTrueWhenDigestPresent() { - var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ); - await Assert.That( image.IsPinned ).IsTrue(); - } - - [Test] - public async Task IsImmutableTrueWhenBothTagAndDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ); - await Assert.That( image.IsPinned ).IsTrue(); - } - // ----------------------- // Equality (record semantics) // ----------------------- diff --git a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs index 71e7216..4ff3d03 100644 --- a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs @@ -54,36 +54,6 @@ public async Task ConstructWithoutNamespaceOnNonRequiredRegistry() { await Assert.That( image.Repository ).IsEqualTo( new Repository( "myapp" ) ); } - // ----------------------- - // IsQualified - // ----------------------- - [Test] - public async Task IsQualifiedAlwaysTrue() { - var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - await Assert.That( image.IsQualified ).IsTrue(); - } - - // ----------------------- - // IsPinned - // ----------------------- - [Test] - public async Task IsPinnedFalseWhenOnlyTag() { - var image = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - await Assert.That( image.IsPinned ).IsFalse(); - } - - [Test] - public async Task IsPinnedTrueWhenDigestPresent() { - var image = new PartialImageRef( "nginx", digest: new Digest( ValidDigest ) ).Qualify(); - await Assert.That( image.IsPinned ).IsTrue(); - } - - [Test] - public async Task IsPinnedTrueWhenBothTagAndDigest() { - var image = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Qualify(); - await Assert.That( image.IsPinned ).IsTrue(); - } - // ----------------------- // ToString // ----------------------- @@ -123,7 +93,7 @@ public async Task ToStringWithoutNamespace() { [Test] public async Task WithTagReplacesTag() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - var updated = original.WithTag( new Tag( "alpine" ) ); + 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" ); @@ -132,7 +102,7 @@ public async Task WithTagReplacesTag() { [Test] public async Task WithTagPreservesOtherProperties() { var original = new PartialImageRef( Registry.GitHub, "myorg", "myapp", Tag.Latest ).Qualify(); - var updated = original.WithTag( new Tag( "v1.0" ) ); + 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" ) ); @@ -143,7 +113,7 @@ public async Task WithTagPreservesOtherProperties() { [Test] public async Task WithTagDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - _ = original.WithTag( new Tag( "alpine" ) ); + _ = original.With( new Tag( "alpine" ) ); await Assert.That( original.Tag ).IsEqualTo( Tag.Latest ); } @@ -154,7 +124,7 @@ public async Task WithTagDoesNotMutateOriginal() { [Test] public async Task OnChangesRegistry() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - var updated = original.On( Registry.GitHub, new Namespace( "myorg" ) ); + 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" ) ); @@ -164,7 +134,7 @@ public async Task OnChangesRegistry() { [Test] public async Task OnPreservesOtherProperties() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - var updated = original.On( Registry.Localhost ); + var updated = original.With( Registry.Localhost ); await Assert.That( updated.Repository ).IsEqualTo( new Repository( "nginx" ) ); await Assert.That( updated.Tag ).IsEqualTo( Tag.Latest ); @@ -173,7 +143,7 @@ public async Task OnPreservesOtherProperties() { [Test] public async Task OnDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); - _ = original.On( Registry.GitHub, new Namespace( "myorg" ) ); + _ = original.With( Registry.GitHub, new Namespace( "myorg" ) ); await Assert.That( original.Registry ).IsEqualTo( Registry.DockerHub ); } @@ -198,7 +168,6 @@ public async Task CanonicalizeWithExistingDigest() { var canonical = qualified.Canonicalize(); await Assert.That( canonical.Digest ).IsEqualTo( new Digest( ValidDigest ) ); - await Assert.That( canonical.IsQualified ).IsTrue(); } [Test] From 3f9850a49b8a64f6da92255e463dd63fed8f7bc3 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:23:56 +0100 Subject: [PATCH 24/36] f --- dotnet/ImageReferences.Tests/{ => Components}/DigestTests.cs | 2 +- .../ImageReferences.Tests/{ => Components}/NamespaceTests.cs | 2 +- dotnet/ImageReferences.Tests/{ => Components}/RegistryTests.cs | 2 +- .../ImageReferences.Tests/{ => Components}/RepositoryTests.cs | 2 +- dotnet/ImageReferences.Tests/{ => Components}/TagTests.cs | 2 +- dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs | 3 ++- dotnet/ImageReferences/PartialImageRef.cs | 1 - 7 files changed, 7 insertions(+), 7 deletions(-) rename dotnet/ImageReferences.Tests/{ => Components}/DigestTests.cs (97%) rename dotnet/ImageReferences.Tests/{ => Components}/NamespaceTests.cs (95%) rename dotnet/ImageReferences.Tests/{ => Components}/RegistryTests.cs (97%) rename dotnet/ImageReferences.Tests/{ => Components}/RepositoryTests.cs (96%) rename dotnet/ImageReferences.Tests/{ => Components}/TagTests.cs (97%) diff --git a/dotnet/ImageReferences.Tests/DigestTests.cs b/dotnet/ImageReferences.Tests/Components/DigestTests.cs similarity index 97% rename from dotnet/ImageReferences.Tests/DigestTests.cs rename to dotnet/ImageReferences.Tests/Components/DigestTests.cs index ddaf9fb..02a1bf7 100644 --- a/dotnet/ImageReferences.Tests/DigestTests.cs +++ b/dotnet/ImageReferences.Tests/Components/DigestTests.cs @@ -1,4 +1,4 @@ -namespace HLabs.ImageReferences.Tests; +namespace HLabs.ImageReferences.Tests.Components; internal sealed class DigestTests { private const string ValidDigest = "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"; diff --git a/dotnet/ImageReferences.Tests/NamespaceTests.cs b/dotnet/ImageReferences.Tests/Components/NamespaceTests.cs similarity index 95% rename from dotnet/ImageReferences.Tests/NamespaceTests.cs rename to dotnet/ImageReferences.Tests/Components/NamespaceTests.cs index a0be940..a2a7c0d 100644 --- a/dotnet/ImageReferences.Tests/NamespaceTests.cs +++ b/dotnet/ImageReferences.Tests/Components/NamespaceTests.cs @@ -1,4 +1,4 @@ -namespace HLabs.ImageReferences.Tests; +namespace HLabs.ImageReferences.Tests.Components; internal sealed class NamespaceTests { [Test] diff --git a/dotnet/ImageReferences.Tests/RegistryTests.cs b/dotnet/ImageReferences.Tests/Components/RegistryTests.cs similarity index 97% rename from dotnet/ImageReferences.Tests/RegistryTests.cs rename to dotnet/ImageReferences.Tests/Components/RegistryTests.cs index 0ff9186..2231bc5 100644 --- a/dotnet/ImageReferences.Tests/RegistryTests.cs +++ b/dotnet/ImageReferences.Tests/Components/RegistryTests.cs @@ -1,4 +1,4 @@ -namespace HLabs.ImageReferences.Tests; +namespace HLabs.ImageReferences.Tests.Components; internal sealed class RegistryTests { [Test] diff --git a/dotnet/ImageReferences.Tests/RepositoryTests.cs b/dotnet/ImageReferences.Tests/Components/RepositoryTests.cs similarity index 96% rename from dotnet/ImageReferences.Tests/RepositoryTests.cs rename to dotnet/ImageReferences.Tests/Components/RepositoryTests.cs index e7069bb..ac43451 100644 --- a/dotnet/ImageReferences.Tests/RepositoryTests.cs +++ b/dotnet/ImageReferences.Tests/Components/RepositoryTests.cs @@ -1,4 +1,4 @@ -namespace HLabs.ImageReferences.Tests; +namespace HLabs.ImageReferences.Tests.Components; internal sealed class RepositoryTests { [Test] diff --git a/dotnet/ImageReferences.Tests/TagTests.cs b/dotnet/ImageReferences.Tests/Components/TagTests.cs similarity index 97% rename from dotnet/ImageReferences.Tests/TagTests.cs rename to dotnet/ImageReferences.Tests/Components/TagTests.cs index 38a0b2f..661d415 100644 --- a/dotnet/ImageReferences.Tests/TagTests.cs +++ b/dotnet/ImageReferences.Tests/Components/TagTests.cs @@ -1,6 +1,6 @@ using Semver; -namespace HLabs.ImageReferences.Tests; +namespace HLabs.ImageReferences.Tests.Components; internal sealed class TagTests { [Test] diff --git a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs index 0c6f6de..91859c5 100644 --- a/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs +++ b/dotnet/ImageReferences.Tests/PartialImageReferenceTests.cs @@ -1,4 +1,5 @@ -using Semver; +using HLabs.ImageReferences.Tests.Components; +using Semver; namespace HLabs.ImageReferences.Tests; diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 6903962..d5c4c55 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -188,7 +188,6 @@ public PartialImageRef( Registry registry, Namespace ns, Repository repository, #endregion - /// /// Returns a new instance with the specified tag. /// From 0de5bb2c9810e6824ff5f1fd85f1574d935f5fd5 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:12:42 +0100 Subject: [PATCH 25/36] tool update --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index afb1e95..b72d926 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "dotnet-stryker": { - "version": "4.8.1", + "version": "4.12.0", "commands": [ "dotnet-stryker" ], From b3eb6ebb1d4b53f0459179546dc67ff73e881895 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:15:14 +0100 Subject: [PATCH 26/36] more deps --- .config/dotnet-tools.json | 2 +- Directory.Packages.props | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index b72d926..7bd6e34 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,7 +10,7 @@ "rollForward": false }, "nuke.globaltool": { - "version": "9.0.4", + "version": "10.1.0", "commands": [ "nuke" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 2907af5..ac124e2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,11 +12,11 @@ - + - + \ No newline at end of file From dca7189be9e130e320f377d0ef31d4eb6fe9efe9 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:34:06 +0100 Subject: [PATCH 27/36] docs consistency --- dotnet/ImageReferences/CanonicalImageRef.cs | 3 +- dotnet/ImageReferences/Components/Digest.cs | 23 +++------ .../ImageReferences/Components/Namespace.cs | 6 +-- dotnet/ImageReferences/ImageRef.cs | 3 +- .../Parsing/StringExtensions.cs | 11 +++-- dotnet/ImageReferences/PartialImageRef.cs | 47 ++++++++++--------- dotnet/ImageReferences/QualifiedImageRef.cs | 21 ++++----- 7 files changed, 54 insertions(+), 60 deletions(-) diff --git a/dotnet/ImageReferences/CanonicalImageRef.cs b/dotnet/ImageReferences/CanonicalImageRef.cs index 9bdbe82..9178038 100644 --- a/dotnet/ImageReferences/CanonicalImageRef.cs +++ b/dotnet/ImageReferences/CanonicalImageRef.cs @@ -82,8 +82,7 @@ public CanonicalImageRef With( Namespace ns ) => new(Registry, ns, Repository, Digest, Tag); /// - /// Returns a string representation of this canonical image reference, - /// including the registry, namespace (if present), repository, digest, and tag (if present). + /// Returns the string representation of this canonical image reference. /// /// A string representation of this canonical image reference. public override string ToString() { diff --git a/dotnet/ImageReferences/Components/Digest.cs b/dotnet/ImageReferences/Components/Digest.cs index 5a6a741..b9f24ca 100644 --- a/dotnet/ImageReferences/Components/Digest.cs +++ b/dotnet/ImageReferences/Components/Digest.cs @@ -3,23 +3,14 @@ namespace HLabs.ImageReferences; /// -/// Represents a digest for a container image e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4. -///

-/// Digests are immutable identifiers that uniquely identify image content i.e., they are content-addressable. -///

+/// Represents a content-addressable digest for a container image (e.g., sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4). +/// Digests are immutable identifiers that uniquely identify image content. ///
/// -/// Using implicit string conversion: -/// -/// Digest digest = "sha256:abc123"; -/// -/// Using constructor /// /// var digest = new Digest("sha256:abc123"); -/// -/// Algorithm prefix is optional: -/// -/// Digest digest1 = "abc123"; +/// Digest digest = "sha256:abc123"; // Implicit conversion from string +/// Digest digest = "abc123"; // Algorithm prefix optional /// /// public sealed record Digest { @@ -36,7 +27,7 @@ private string Value { /// Initializes a new instance of the class. ///
/// The digest value. Can be in format sha256:abc123 or just abc123. - /// Thrown when is not a valid digest. + /// 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) ); @@ -89,7 +80,7 @@ public Digest( string value ) { ///
/// The digest value. /// A new instance. - /// Thrown when is not a valid digest. + /// Thrown when is an invalid digest. public static Digest FromString( string value ) { return new(value); } @@ -97,6 +88,6 @@ public static Digest FromString( string value ) { /// /// Returns the string representation of this digest. /// - /// The digest in format sha256:abc123. + /// The digest in format sha256:hash. public override string ToString() => Value; } \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Namespace.cs b/dotnet/ImageReferences/Components/Namespace.cs index d0c3d0c..efae520 100644 --- a/dotnet/ImageReferences/Components/Namespace.cs +++ b/dotnet/ImageReferences/Components/Namespace.cs @@ -7,11 +7,11 @@ namespace HLabs.ImageReferences; /// Namespaces are used to organize repositories within a registry. ///
/// -/// Examples of valid namespaces: /// /// 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( @@ -27,8 +27,8 @@ private string Name { /// /// Initializes a new instance of the class. /// - /// The namespace name. Cannot be null, empty, or contain forward slashes. - /// Thrown when name is null, empty, whitespace-only, contains leading/trailing whitespace, or contains a forward slash. + /// 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) ); diff --git a/dotnet/ImageReferences/ImageRef.cs b/dotnet/ImageReferences/ImageRef.cs index 43a73f8..8440de3 100644 --- a/dotnet/ImageReferences/ImageRef.cs +++ b/dotnet/ImageReferences/ImageRef.cs @@ -1,7 +1,8 @@ namespace HLabs.ImageReferences; /// -/// Base class for container image references, providing common properties. +/// Base class for container image references. +/// Provides common properties for registry, namespace, repository, tag, and digest. /// public abstract record ImageRef { /// diff --git a/dotnet/ImageReferences/Parsing/StringExtensions.cs b/dotnet/ImageReferences/Parsing/StringExtensions.cs index 066da33..79d46f8 100644 --- a/dotnet/ImageReferences/Parsing/StringExtensions.cs +++ b/dotnet/ImageReferences/Parsing/StringExtensions.cs @@ -22,15 +22,20 @@ public static class StringExtensions { public PartialImageRef Image() => PartialImageRef.Parse( imageReference ); /// - /// Creates a qualified container image reference. + /// Creates a qualified container image reference from a string. /// - /// Thrown if the image reference is not in a canonical form. + /// 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 pinned container image reference. That is, a reference that is guaranteed to resolve to the same image (content-addressable). + /// 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.cs b/dotnet/ImageReferences/PartialImageRef.cs index d5c4c55..5310e59 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -3,7 +3,7 @@ namespace HLabs.ImageReferences; // TODO support platform // TODO add no-arg Canonicalize? /// -/// Partial image reference. +/// Partial image reference that may have some components unspecified. /// /// /// Use to convert to a fully qualified reference. @@ -191,7 +191,7 @@ public PartialImageRef( Registry registry, Namespace ns, Repository repository, /// /// Returns a new instance with the specified tag. /// - /// The tag, or null to remove it. + /// The tag. /// A new with the specified tag. public PartialImageRef With( Tag? tag ) => new(Repository, tag, Registry, Namespace, Digest); @@ -199,7 +199,7 @@ public PartialImageRef With( Tag? tag ) => /// /// Returns a new instance with the specified registry. /// - /// The registry, or null to remove it. + /// The registry. /// A new with the specified registry. public PartialImageRef With( Registry? registry ) => new(Repository, Tag, registry, Namespace, Digest); @@ -216,7 +216,7 @@ public PartialImageRef With( Registry registry, Namespace ns ) => /// /// Returns a new instance with the specified namespace. /// - /// The namespace, or null to remove it. + /// The namespace. /// A new with the specified namespace. public PartialImageRef With( Namespace? ns ) => // TODO remember registry/namespace requirement @@ -225,7 +225,7 @@ public PartialImageRef With( Namespace? ns ) => /// /// Returns a new instance with the specified digest. /// - /// The digest, or null to remove it. + /// The digest. /// A new with the specified digest. public PartialImageRef With( Digest? digest ) => new(Repository, Tag, Registry, Namespace, digest); @@ -233,32 +233,32 @@ public PartialImageRef With( Digest? 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 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? canonical, out string? reason ) { + public bool TryQualify( out QualifiedImageRef? qualified, out string? reason ) { try { - canonical = Qualify(); + qualified = Qualify(); reason = null; return true; } #pragma warning disable CA1031 catch ( Exception ex ) { #pragma warning restore CA1031 - canonical = null; + qualified = null; reason = ex.Message; return false; } } /// - /// Converts this reference to a canonical form. - /// Attempts to fill in defaults for missing registry/namespace based on common conventions (e.g. Docker Hub defaults). + /// 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 behavior when the reference is not in a canonical form. - /// Throws InvalidOperationException if the reference is not in a qualified form. - /// A canonical image reference. + /// 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; @@ -274,10 +274,10 @@ public QualifiedImageRef Qualify( QualificationMode mode = QualificationMode.Def } /// - /// Canonicalizes the image reference using the current digest. + /// Converts this reference to a canonical (digest-pinned) form using the current digest. /// - /// Whether to maintain or exclude the tag. - /// How to handle missing components when qualifying. + /// 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( @@ -288,11 +288,11 @@ public CanonicalImageRef Canonicalize( } /// - /// Canonicalizes the image reference with a specific digest. + /// Converts this reference to a canonical (digest-pinned) form with a specific digest. /// /// The digest to use. - /// Whether to maintain or exclude the tag. - /// How to handle missing components when qualifying. + /// 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, @@ -303,14 +303,15 @@ public CanonicalImageRef Canonicalize( } /// - /// Returns a string representation of this image reference. + /// Returns the string representation of this partial image reference. /// - /// A string representation of this 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}{Repository}{tag}{digest}"; + return $"{reg}{ns}{repo}{tag}{digest}"; } } \ No newline at end of file diff --git a/dotnet/ImageReferences/QualifiedImageRef.cs b/dotnet/ImageReferences/QualifiedImageRef.cs index 7af74ab..659bb3f 100644 --- a/dotnet/ImageReferences/QualifiedImageRef.cs +++ b/dotnet/ImageReferences/QualifiedImageRef.cs @@ -1,10 +1,7 @@ namespace HLabs.ImageReferences; -// ----------------------------- -// Qualified image reference: fully qualified (registry, namespace, repository, tag or digest) -// ----------------------------- /// -/// Qualified image reference that can address a specific image. +/// 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. @@ -40,7 +37,7 @@ internal QualifiedImageRef( Registry registry, Namespace? ns, Repository reposit /// /// Returns a new instance with the specified tag. /// - /// The tag, or null to remove it. + /// The tag. /// A new with the specified tag. public QualifiedImageRef With( Tag? tag ) => new(Registry, Namespace, Repository, tag, Digest); @@ -65,7 +62,7 @@ public QualifiedImageRef With( Registry registry, Namespace ns ) => /// /// Returns a new instance with the specified namespace. /// - /// The namespace, or null to remove it. + /// The namespace. /// A new with the specified namespace. public QualifiedImageRef With( Namespace? ns ) => new(Registry, ns, Repository, Tag, Digest); @@ -73,17 +70,17 @@ public QualifiedImageRef With( Namespace? ns ) => /// /// Returns a new instance with the specified digest. /// - /// The digest, or null to remove it. + /// The digest. /// A new with the specified digest. public QualifiedImageRef With( Digest? digest ) => new(Registry, Namespace, Repository, Tag, digest); /// - /// Creates a digest-pinned image reference. + /// 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 (immutable) image reference. + /// A canonical image reference. /// Thrown when digest is null. public CanonicalImageRef Canonicalize( Digest digest, CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { ArgumentNullException.ThrowIfNull( digest ); @@ -93,10 +90,10 @@ public CanonicalImageRef Canonicalize( Digest digest, CanonicalizationMode mode } /// - /// Creates a digest-pinned image reference using the current 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. - /// A canonical (immutable) image reference. + /// A canonical image reference. /// Thrown when digest is not present. public CanonicalImageRef Canonicalize( CanonicalizationMode mode = CanonicalizationMode.ExcludeTag ) { if ( Digest is null ) { @@ -109,7 +106,7 @@ public CanonicalImageRef Canonicalize( CanonicalizationMode mode = Canonicalizat } /// - /// Returns a string representation of this qualified image reference. + /// Returns the string representation of this qualified image reference. /// /// A string representation of this qualified image reference. public override string ToString() { From 01990aab585e90c56ea21b75ca119deb05542f52 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:48:28 +0100 Subject: [PATCH 28/36] tests --- .../CanonicalImageRefTests.cs | 2 +- dotnet/ImageReferences.Tests/ImageIdTests.cs | 195 ++++++++++++++++++ .../QualifiedImageRefTests.cs | 8 +- .../StringExtensionsTests.cs | 172 +++++++++++++++ 4 files changed, 372 insertions(+), 5 deletions(-) create mode 100644 dotnet/ImageReferences.Tests/ImageIdTests.cs create mode 100644 dotnet/ImageReferences.Tests/StringExtensionsTests.cs diff --git a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs index 7355a8f..3620c1a 100644 --- a/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/CanonicalImageRefTests.cs @@ -112,7 +112,7 @@ public async Task WithTagAddsTag() { } [Test] - public async Task WithTagReplacesExistingTag() { + public async Task WithTagReplacesTag() { var original = new PartialImageRef( "nginx", Tag.Latest, digest: new Digest( ValidDigest ) ).Canonicalize(); var updated = original.With( new Tag( "alpine" ) ); diff --git a/dotnet/ImageReferences.Tests/ImageIdTests.cs b/dotnet/ImageReferences.Tests/ImageIdTests.cs new file mode 100644 index 0000000..993ceab --- /dev/null +++ b/dotnet/ImageReferences.Tests/ImageIdTests.cs @@ -0,0 +1,195 @@ +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() ); + } +} + + diff --git a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs index 4ff3d03..f664abf 100644 --- a/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs +++ b/dotnet/ImageReferences.Tests/QualifiedImageRefTests.cs @@ -119,10 +119,10 @@ public async Task WithTagDoesNotMutateOriginal() { } // ----------------------- - // On (change registry) + // WithRegistry (change registry) // ----------------------- [Test] - public async Task OnChangesRegistry() { + public async Task WithRegistryChangesRegistry() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); var updated = original.With( Registry.GitHub, new Namespace( "myorg" ) ); @@ -132,7 +132,7 @@ public async Task OnChangesRegistry() { } [Test] - public async Task OnPreservesOtherProperties() { + public async Task WithRegistryPreservesOtherProperties() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); var updated = original.With( Registry.Localhost ); @@ -141,7 +141,7 @@ public async Task OnPreservesOtherProperties() { } [Test] - public async Task OnDoesNotMutateOriginal() { + public async Task WithRegistryDoesNotMutateOriginal() { var original = new PartialImageRef( "nginx", Tag.Latest ).Qualify(); _ = original.With( Registry.GitHub, new Namespace( "myorg" ) ); diff --git a/dotnet/ImageReferences.Tests/StringExtensionsTests.cs b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000..fe08ba6 --- /dev/null +++ b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs @@ -0,0 +1,172 @@ +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() { + Assert.Throws( () => "".Image() ); + } + + [Test] + public void ImageExtensionThrowsOnNullString() { + string? nullString = null; + 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() { + Assert.Throws( () => "".QualifiedImage() ); + } + + // ----------------------- + // 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 ); + } +} + + From 24ff2a6c1df82327cfc04715518e6d54a165a127 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:54:00 +0100 Subject: [PATCH 29/36] f --- Directory.Build.props | 2 -- build/NukeBuild.Pack.cs | 55 ++++++++++++++++++++--------------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index e784c80..ec2baf2 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,9 +15,7 @@ enable false All - true true - $(NoWarn);SA0001 diff --git a/build/NukeBuild.Pack.cs b/build/NukeBuild.Pack.cs index 4d2680f..a4468ed 100644 --- a/build/NukeBuild.Pack.cs +++ b/build/NukeBuild.Pack.cs @@ -73,39 +73,38 @@ void ClearNuGetCacheFor( string nupkgPath ) { Directory.Delete( cachedPath, recursive: true ); } -private (string? Id, string? Version) ReadPackageIdentity( string nupkgPath ) { - using var archive = ZipFile.OpenRead( nupkgPath ); + 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 ) ); + 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 ); - } + 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 ); + using var stream = nuspecEntry.Open(); + var document = XDocument.Load( stream ); - // NuSpec uses namespaces — ignore them safely - var metadata = document - .Descendants() - .FirstOrDefault( e => e.Name.LocalName == "metadata" ); + var metadata = document + .Descendants() + .FirstOrDefault( e => e.Name.LocalName == "metadata" ); - var id = metadata? - .Elements() - .FirstOrDefault( e => e.Name.LocalName == "id" ) - ?.Value; + var id = metadata? + .Elements() + .FirstOrDefault( e => e.Name.LocalName == "id" ) + ?.Value; - var version = metadata? - .Elements() - .FirstOrDefault( e => e.Name.LocalName == "version" ) - ?.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 ); - } + if ( id is null || version is null ) { + Log.Warning( "Could not read id/version from nuspec in {Nupkg}", nupkgPath ); + } - return ( id, version ); -} -} + return ( id, version ); + } +} \ No newline at end of file From 7e94f1043f67cd3813f30855f471e20473e283ad Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:05:13 +0100 Subject: [PATCH 30/36] f --- dotnet/ImageReferences/Components/Registry.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dotnet/ImageReferences/Components/Registry.cs b/dotnet/ImageReferences/Components/Registry.cs index b8a7de9..4503503 100644 --- a/dotnet/ImageReferences/Components/Registry.cs +++ b/dotnet/ImageReferences/Components/Registry.cs @@ -62,4 +62,14 @@ public static Registry FromString( string host ) { /// /// 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() { + return Host.GetHashCode(); + } } \ No newline at end of file From 6fb26e56059bf95fa69b9778d1c98c795d4f17df Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:11:16 +0100 Subject: [PATCH 31/36] fix warnings --- dotnet/ImageReferences.Tests/ImageIdTests.cs | 7 +++---- dotnet/ImageReferences.Tests/StringExtensionsTests.cs | 9 ++++++--- dotnet/ImageReferences/Components/Registry.cs | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/dotnet/ImageReferences.Tests/ImageIdTests.cs b/dotnet/ImageReferences.Tests/ImageIdTests.cs index 993ceab..d09093a 100644 --- a/dotnet/ImageReferences.Tests/ImageIdTests.cs +++ b/dotnet/ImageReferences.Tests/ImageIdTests.cs @@ -77,7 +77,8 @@ public void Sha1AlgorithmThrows() { [Test] public void Sha512AlgorithmThrows() { - var sha512Hash = "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"; + var sha512Hash = + "b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb980b1d7785e5976ec049b46df5f1326af5a2ea6d103fd07c95385ffab0cacbc86"; Assert.Throws( () => new ImageId( $"sha512:{sha512Hash}" ) ); } @@ -190,6 +191,4 @@ public async Task GetHashCodeDifferentForDifferentIds() { var b = new ImageId( differentHash ); await Assert.That( a.GetHashCode() ).IsNotEqualTo( b.GetHashCode() ); } -} - - +} \ No newline at end of file diff --git a/dotnet/ImageReferences.Tests/StringExtensionsTests.cs b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs index fe08ba6..6c65408 100644 --- a/dotnet/ImageReferences.Tests/StringExtensionsTests.cs +++ b/dotnet/ImageReferences.Tests/StringExtensionsTests.cs @@ -54,12 +54,15 @@ public async Task ImageExtensionParsesRepositoryOnly() { [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() ); } @@ -97,7 +100,9 @@ public async Task QualifiedImageExtensionWorksWithDigest() { [Test] public void QualifiedImageExtensionThrowsOnInvalid() { +#pragma warning disable SA1122 Assert.Throws( () => "".QualifiedImage() ); +#pragma warning restore SA1122 } // ----------------------- @@ -167,6 +172,4 @@ public async Task CanonicalImageExtensionRoundTrips() { var canonical = input.CanonicalImage(); await Assert.That( canonical.ToString() ).IsEqualTo( input ); } -} - - +} \ No newline at end of file diff --git a/dotnet/ImageReferences/Components/Registry.cs b/dotnet/ImageReferences/Components/Registry.cs index 4503503..70a7712 100644 --- a/dotnet/ImageReferences/Components/Registry.cs +++ b/dotnet/ImageReferences/Components/Registry.cs @@ -70,6 +70,8 @@ public bool Equals( Registry? other ) { /// public override int GetHashCode() { + #pragma warning disable CA1307 return Host.GetHashCode(); +#pragma warning restore CA1307 } } \ No newline at end of file From ebd9fbe207ac62bdb78571183997b378be4028f3 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:32:10 +0100 Subject: [PATCH 32/36] f --- dotnet/ImageReferences/PartialImageRef.cs | 5 ----- dotnet/ImageReferences/README.md | 20 ++++++++------------ 2 files changed, 8 insertions(+), 17 deletions(-) diff --git a/dotnet/ImageReferences/PartialImageRef.cs b/dotnet/ImageReferences/PartialImageRef.cs index 5310e59..4bb16c3 100644 --- a/dotnet/ImageReferences/PartialImageRef.cs +++ b/dotnet/ImageReferences/PartialImageRef.cs @@ -107,10 +107,6 @@ public PartialImageRef( Registry registry, Repository repository ) : this( repository, null, registry, null, null ) { } - // Overload clashes with Namespace + Repository + Tag when using string literals (implicit conversions), so not included for now. - // The more common use case is probably the other one. - // TODO decide - /// /// Initializes a new instance of the class. /// @@ -219,7 +215,6 @@ public PartialImageRef With( Registry registry, Namespace ns ) => /// The namespace. /// A new with the specified namespace. public PartialImageRef With( Namespace? ns ) => - // TODO remember registry/namespace requirement new(Repository, Tag, Registry, ns, Digest); /// diff --git a/dotnet/ImageReferences/README.md b/dotnet/ImageReferences/README.md index 1f03f61..38ba76b 100644 --- a/dotnet/ImageReferences/README.md +++ b/dotnet/ImageReferences/README.md @@ -1,4 +1,4 @@ -# HLabs.ImageReferences +# `HLabs.ImageReferences` Strongly-typed container image references for .NET. @@ -9,7 +9,7 @@ dotnet add package HLabs.ImageReferences ## Getting Started ```csharp -var partial = "nginx".Image(); // -> nginx +var partial = "nginx".Image(); // nginx var qualified = partial.Qualified(); // docker.io/library/nginx:latest @@ -29,19 +29,15 @@ Tag tag = image.Tag; // 3.1.0 ### Modifying references -You are always guarenteed to have a structurally valid reference when modifying: - ```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... +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 -Common registries and tags are provided as static members: - ```csharp Registry.DockerHub // docker.io Registry.GitHub // ghcr.io @@ -76,6 +72,6 @@ static class MyExtensions 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 +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 ``` From f1a871cbd735f33991c4691b9502d18b94be5b3b Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:38:12 +0100 Subject: [PATCH 33/36] whale --- dotnet/ImageReferences/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/ImageReferences/README.md b/dotnet/ImageReferences/README.md index 38ba76b..fb4e8ff 100644 --- a/dotnet/ImageReferences/README.md +++ b/dotnet/ImageReferences/README.md @@ -1,4 +1,4 @@ -# `HLabs.ImageReferences` +# `HLabs.ImageReferences` 🐋 Strongly-typed container image references for .NET. From 11d37f64e484657fbf1a6d40ff987cdbbdf50501 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:12:17 +0100 Subject: [PATCH 34/36] f --- Directory.Build.props | 2 +- .../ImageReferences.Extensions.Nuke.csproj | 2 +- dotnet/ImageReferences.Extensions.Nuke/README.md | 4 ++-- dotnet/ImageReferences/ImageReferences.csproj | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index ec2baf2..6766019 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 1.0.0-preview.1 + 0.0.0-local hojmark Copyright (c) hojmark https://github.com/hojmark/labs diff --git a/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj index 20e0957..747eecd 100644 --- a/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj +++ b/dotnet/ImageReferences.Extensions.Nuke/ImageReferences.Extensions.Nuke.csproj @@ -1,7 +1,7 @@  - Describe, validate, and manipulate container image references + NUKE extensions for HLabs.ImageReferences NUKE Docker container containers true diff --git a/dotnet/ImageReferences.Extensions.Nuke/README.md b/dotnet/ImageReferences.Extensions.Nuke/README.md index 599f8ea..76767b5 100644 --- a/dotnet/ImageReferences.Extensions.Nuke/README.md +++ b/dotnet/ImageReferences.Extensions.Nuke/README.md @@ -1,3 +1,3 @@ -# HLabs.Containers.ImageReferences.Extensions.Nuke +# `HLabs.ImageReferences.Extensions.Nuke` -This repository contains NUKE extensions for HLabs.Containers.ImageReferences. \ No newline at end of file +NUKE extensions for [`HLabs.ImageReferences`](https://www.nuget.org/packages/HLabs.ImageReferences/) \ No newline at end of file diff --git a/dotnet/ImageReferences/ImageReferences.csproj b/dotnet/ImageReferences/ImageReferences.csproj index 34712e0..841aaff 100644 --- a/dotnet/ImageReferences/ImageReferences.csproj +++ b/dotnet/ImageReferences/ImageReferences.csproj @@ -1,7 +1,7 @@  - Describe, validate, and manipulate container image references + Strongly-typed container image references. Build, parse and manipulate. Docker Podman container containers image true From bdab9f2e8691e6cde8e5f618da7c69b734a194aa Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:15:43 +0100 Subject: [PATCH 35/36] f --- HLabs.slnx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/HLabs.slnx b/HLabs.slnx index aa0e3bc..690c325 100644 --- a/HLabs.slnx +++ b/HLabs.slnx @@ -5,17 +5,17 @@ - - - - + + + + - - - + + + From 527f9aec8c7e9eba17f4353fe90cb46ffedd7ab8 Mon Sep 17 00:00:00 2001 From: hojmark <1203136+hojmark@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:17:58 +0100 Subject: [PATCH 36/36] f --- AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2334082..beb4ffa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,8 @@ ## Project structure -- `dotnet/Containers.ImageReferences/` - Build, parse, and manipulate container image references -- `dotnet/Containers.ImageReferences.Extensions.Nuke/` - NUKE build extensions +- `dotnet/ImageReferences/` - Strongly-typed container image references for .NET. +- `dotnet/ImageReferences.Extensions.Nuke/` - NUKE build extensions Each project may have: