fix(container): handle trailing slash and IPv6 registries
- Normalize RegistryDomain by trimming trailing slash to avoid double slashes in image references - Detect bracketed IPv6 addresses (e.g., [fd00::1]/image or [fd00::1]:5000/image) as explicit registries
- commit
- 2b4e30bfb535a68b4e650042df2eefb245372ef3
- author
- bnema · · 2026-02-03 20:41 UTC
- parent
- 855fc04
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +38 -3
M internal/usecase/container/service.go +17 -3
| @@ -636,8 +636,11 @@ func (s *Service) buildImageRef(image string) string { | |||
| 636 | 636 | return image | |
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | + | // Normalize registry domain by trimming any trailing slash | |
| 640 | + | reg := strings.TrimSuffix(s.config.RegistryDomain, "/") | |
| 641 | + | ||
| 639 | 642 | // Check if image already has the registry domain prefix | |
| 640 | - | prefix := s.config.RegistryDomain + "/" | |
| 643 | + | prefix := reg + "/" | |
| 641 | 644 | if strings.HasPrefix(image, prefix) { | |
| 642 | 645 | return image | |
| 643 | 646 | } | |
| @@ -647,15 +650,17 @@ func (s *Service) buildImageRef(image string) string { | |||
| 647 | 650 | // - "docker.io/library/nginx:latest" (has dot and slash) | |
| 648 | 651 | // - "localhost:5001/myapp:latest" (has host:port/ pattern) | |
| 649 | 652 | // - "myregistry:5000/app:v1" (has host:port/ pattern) | |
| 653 | + | // - "[fd00::1]:5000/app:v1" (IPv6 with port) | |
| 650 | 654 | if hasExplicitRegistry(image) { | |
| 651 | 655 | return image | |
| 652 | 656 | } | |
| 653 | 657 | ||
| 654 | - | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | |
| 658 | + | return fmt.Sprintf("%s/%s", reg, image) | |
| 655 | 659 | } | |
| 656 | 660 | ||
| 657 | 661 | // hasExplicitRegistry checks if an image reference already includes an explicit registry. | |
| 658 | - | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", "registry:8080/image". | |
| 662 | + | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", | |
| 663 | + | // "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image". | |
| 659 | 664 | func hasExplicitRegistry(image string) bool { | |
| 660 | 665 | // Find the first slash which separates registry from image name | |
| 661 | 666 | slashIdx := strings.Index(image, "/") | |
| @@ -666,6 +671,15 @@ func hasExplicitRegistry(image string) bool { | |||
| 666 | 671 | // Extract the part before the first slash (potential registry) | |
| 667 | 672 | registryPart := image[:slashIdx] | |
| 668 | 673 | ||
| 674 | + | // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000") | |
| 675 | + | if strings.HasPrefix(registryPart, "[") { | |
| 676 | + | // Look for closing bracket | |
| 677 | + | if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 { | |
| 678 | + | // Valid bracketed IPv6: either ends at ] or has ]:port | |
| 679 | + | return true | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | ||
| 669 | 683 | // Check for localhost (with or without port) | |
| 670 | 684 | if registryPart == "localhost" { | |
| 671 | 685 | return true | |
| @@ -636,8 +636,11 @@ func (s *Service) buildImageRef(image string) string { | |||||
| 636 | return image | 636 | return image | ||
| 637 | } | 637 | } | ||
| 638 | 638 | ||||
| 639 | + | // Normalize registry domain by trimming any trailing slash | |||
| 640 | + | reg := strings.TrimSuffix(s.config.RegistryDomain, "/") | |||
| 641 | + | ||||
| 639 | // Check if image already has the registry domain prefix | 642 | // Check if image already has the registry domain prefix | ||
| 640 | - | prefix := s.config.RegistryDomain + "/" | 643 | + | prefix := reg + "/" |
| 641 | if strings.HasPrefix(image, prefix) { | 644 | if strings.HasPrefix(image, prefix) { | ||
| 642 | return image | 645 | return image | ||
| 643 | } | 646 | } | ||
| @@ -647,15 +650,17 @@ func (s *Service) buildImageRef(image string) string { | |||||
| 647 | // - "docker.io/library/nginx:latest" (has dot and slash) | 650 | // - "docker.io/library/nginx:latest" (has dot and slash) | ||
| 648 | // - "localhost:5001/myapp:latest" (has host:port/ pattern) | 651 | // - "localhost:5001/myapp:latest" (has host:port/ pattern) | ||
| 649 | // - "myregistry:5000/app:v1" (has host:port/ pattern) | 652 | // - "myregistry:5000/app:v1" (has host:port/ pattern) | ||
| 653 | + | // - "[fd00::1]:5000/app:v1" (IPv6 with port) | |||
| 650 | if hasExplicitRegistry(image) { | 654 | if hasExplicitRegistry(image) { | ||
| 651 | return image | 655 | return image | ||
| 652 | } | 656 | } | ||
| 653 | 657 | ||||
| 654 | - | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | 658 | + | return fmt.Sprintf("%s/%s", reg, image) |
| 655 | } | 659 | } | ||
| 656 | 660 | ||||
| 657 | // hasExplicitRegistry checks if an image reference already includes an explicit registry. | 661 | // hasExplicitRegistry checks if an image reference already includes an explicit registry. | ||
| 658 | - | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", "registry:8080/image". | 662 | + | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", |
| 663 | + | // "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image". | |||
| 659 | func hasExplicitRegistry(image string) bool { | 664 | func hasExplicitRegistry(image string) bool { | ||
| 660 | // Find the first slash which separates registry from image name | 665 | // Find the first slash which separates registry from image name | ||
| 661 | slashIdx := strings.Index(image, "/") | 666 | slashIdx := strings.Index(image, "/") | ||
| @@ -666,6 +671,15 @@ func hasExplicitRegistry(image string) bool { | |||||
| 666 | // Extract the part before the first slash (potential registry) | 671 | // Extract the part before the first slash (potential registry) | ||
| 667 | registryPart := image[:slashIdx] | 672 | registryPart := image[:slashIdx] | ||
| 668 | 673 | ||||
| 674 | + | // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000") | |||
| 675 | + | if strings.HasPrefix(registryPart, "[") { | |||
| 676 | + | // Look for closing bracket | |||
| 677 | + | if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 { | |||
| 678 | + | // Valid bracketed IPv6: either ends at ] or has ]:port | |||
| 679 | + | return true | |||
| 680 | + | } | |||
| 681 | + | } | |||
| 682 | + | ||||
| 669 | // Check for localhost (with or without port) | 683 | // Check for localhost (with or without port) | ||
| 670 | if registryPart == "localhost" { | 684 | if registryPart == "localhost" { | ||
| 671 | return true | 685 | return true | ||
M internal/usecase/container/service_test.go +21 -0
| @@ -828,6 +828,27 @@ func TestBuildImageRef(t *testing.T) { | |||
| 828 | 828 | registryDomain: "localhost:5000", | |
| 829 | 829 | wantRef: "localhost/myapp:latest", | |
| 830 | 830 | }, | |
| 831 | + | { | |
| 832 | + | name: "registry domain with trailing slash", | |
| 833 | + | image: "myapp:latest", | |
| 834 | + | registryAuthEnabled: true, | |
| 835 | + | registryDomain: "registry.example.com/", | |
| 836 | + | wantRef: "registry.example.com/myapp:latest", | |
| 837 | + | }, | |
| 838 | + | { | |
| 839 | + | name: "ipv6 registry without port", | |
| 840 | + | image: "[fd00::1]/myapp:latest", | |
| 841 | + | registryAuthEnabled: true, | |
| 842 | + | registryDomain: "localhost:5000", | |
| 843 | + | wantRef: "[fd00::1]/myapp:latest", | |
| 844 | + | }, | |
| 845 | + | { | |
| 846 | + | name: "ipv6 registry with port", | |
| 847 | + | image: "[fd00::1]:5000/myapp:latest", | |
| 848 | + | registryAuthEnabled: true, | |
| 849 | + | registryDomain: "localhost:5000", | |
| 850 | + | wantRef: "[fd00::1]:5000/myapp:latest", | |
| 851 | + | }, | |
| 831 | 852 | } | |
| 832 | 853 | ||
| 833 | 854 | for _, tt := range tests { | |
| @@ -828,6 +828,27 @@ func TestBuildImageRef(t *testing.T) { | |||||
| 828 | registryDomain: "localhost:5000", | 828 | registryDomain: "localhost:5000", | ||
| 829 | wantRef: "localhost/myapp:latest", | 829 | wantRef: "localhost/myapp:latest", | ||
| 830 | }, | 830 | }, | ||
| 831 | + | { | |||
| 832 | + | name: "registry domain with trailing slash", | |||
| 833 | + | image: "myapp:latest", | |||
| 834 | + | registryAuthEnabled: true, | |||
| 835 | + | registryDomain: "registry.example.com/", | |||
| 836 | + | wantRef: "registry.example.com/myapp:latest", | |||
| 837 | + | }, | |||
| 838 | + | { | |||
| 839 | + | name: "ipv6 registry without port", | |||
| 840 | + | image: "[fd00::1]/myapp:latest", | |||
| 841 | + | registryAuthEnabled: true, | |||
| 842 | + | registryDomain: "localhost:5000", | |||
| 843 | + | wantRef: "[fd00::1]/myapp:latest", | |||
| 844 | + | }, | |||
| 845 | + | { | |||
| 846 | + | name: "ipv6 registry with port", | |||
| 847 | + | image: "[fd00::1]:5000/myapp:latest", | |||
| 848 | + | registryAuthEnabled: true, | |||
| 849 | + | registryDomain: "localhost:5000", | |||
| 850 | + | wantRef: "[fd00::1]:5000/myapp:latest", | |||
| 851 | + | }, | |||
| 831 | } | 852 | } | ||
| 832 | 853 | ||||
| 833 | for _, tt := range tests { | 854 | for _, tt := range tests { | ||