detached 757fa05

fix(container): handle image refs with port numbers and clean up temp tags

- Fix normalizeImageRef to correctly parse localhost:5000/image:tag
- The last colon before a tag has no slash after it
- Add cleanup of temporary localhost:5000 tags after canonical tagging
- Add test for normalizeImageRef with port numbers
commit
757fa05f346d9bab4c81b3386709b9c8a155e4fb
author
brice · · 2026-01-14 06:23 UTC
parent
118d20e
signature
unsigned
2 files · +48 -3
M internal/usecase/container/service.go +33 -3
@@ -509,6 +509,14 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
509509 return "", err
510510 }
511511
512+ // Clean up the temporary pull reference tag to avoid duplicate entries
513+ if pullRef != imageRef {
514+ if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
515+ // Log but don't fail - the canonical tag is already applied
516+ log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
517+ }
518+ }
519+
512520 log.Info().Msg("image pulled successfully")
513521 return imageRef, nil
514522 }
@@ -534,6 +542,12 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
534542 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
535543 return false, err
536544 }
545+ // Clean up the temporary pull reference tag
546+ if pullRef != imageRef {
547+ if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
548+ log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
549+ }
550+ }
537551 log.Info().Msg("image found locally, skipping pull")
538552 return true, nil
539553 }
@@ -890,14 +904,30 @@ func (s *Service) stopAllManagedContainers(ctx context.Context) error {
890904 // Utility functions
891905
892906 func normalizeImageRef(image string) string {
893- parts := strings.Split(image, ":")
894- repo := parts[0]
907+ // Extract repository from image reference, handling port numbers correctly.
908+ // Examples:
909+ // nginx:latest -> docker.io/library/nginx
910+ // user/repo:tag -> docker.io/user/repo
911+ // localhost:5000/image:tag -> localhost:5000/image
912+ // registry.example.com/image:tag -> registry.example.com/image
913+
914+ // Find the tag separator: the last colon that isn't part of a port number.
915+ // A colon is part of a port if there's no slash after it until the next colon.
916+ repo := image
917+ lastColon := strings.LastIndex(image, ":")
918+ if lastColon != -1 {
919+ afterColon := image[lastColon+1:]
920+ // If there's no slash after the colon, it's the tag separator
921+ if !strings.Contains(afterColon, "/") {
922+ repo = image[:lastColon]
923+ }
924+ }
895925
896926 if !strings.Contains(repo, "/") {
897927 return "docker.io/library/" + repo
898928 }
899929
900- if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {
930+ if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
901931 return "docker.io/" + repo
902932 }
903933
@@ -509,6 +509,14 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
509 return "", err509 return "", err
510 }510 }
511 511
512+ // Clean up the temporary pull reference tag to avoid duplicate entries
513+ if pullRef != imageRef {
514+ if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
515+ // Log but don't fail - the canonical tag is already applied
516+ log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
517+ }
518+ }
519+
512 log.Info().Msg("image pulled successfully")520 log.Info().Msg("image pulled successfully")
513 return imageRef, nil521 return imageRef, nil
514 }522 }
@@ -534,6 +542,12 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
534 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {542 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
535 return false, err543 return false, err
536 }544 }
545+ // Clean up the temporary pull reference tag
546+ if pullRef != imageRef {
547+ if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
548+ log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
549+ }
550+ }
537 log.Info().Msg("image found locally, skipping pull")551 log.Info().Msg("image found locally, skipping pull")
538 return true, nil552 return true, nil
539 }553 }
@@ -890,14 +904,30 @@ func (s *Service) stopAllManagedContainers(ctx context.Context) error {
890 // Utility functions904 // Utility functions
891 905
892 func normalizeImageRef(image string) string {906 func normalizeImageRef(image string) string {
893- parts := strings.Split(image, ":")907+ // Extract repository from image reference, handling port numbers correctly.
894- repo := parts[0]908+ // Examples:
909+ // nginx:latest -> docker.io/library/nginx
910+ // user/repo:tag -> docker.io/user/repo
911+ // localhost:5000/image:tag -> localhost:5000/image
912+ // registry.example.com/image:tag -> registry.example.com/image
913+
914+ // Find the tag separator: the last colon that isn't part of a port number.
915+ // A colon is part of a port if there's no slash after it until the next colon.
916+ repo := image
917+ lastColon := strings.LastIndex(image, ":")
918+ if lastColon != -1 {
919+ afterColon := image[lastColon+1:]
920+ // If there's no slash after the colon, it's the tag separator
921+ if !strings.Contains(afterColon, "/") {
922+ repo = image[:lastColon]
923+ }
924+ }
895 925
896 if !strings.Contains(repo, "/") {926 if !strings.Contains(repo, "/") {
897 return "docker.io/library/" + repo927 return "docker.io/library/" + repo
898 }928 }
899 929
900- if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") {930+ if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
901 return "docker.io/" + repo931 return "docker.io/" + repo
902 }932 }
903 933
M internal/usecase/container/service_test.go +15 -0
@@ -556,6 +556,21 @@ func TestNormalizeImageRef(t *testing.T) {
556556 input: "nginx:latest",
557557 expected: "docker.io/library/nginx",
558558 },
559+ {
560+ name: "localhost with port",
561+ input: "localhost:5000/myapp:latest",
562+ expected: "localhost:5000/myapp",
563+ },
564+ {
565+ name: "localhost with port no tag",
566+ input: "localhost:5000/myapp",
567+ expected: "localhost:5000/myapp",
568+ },
569+ {
570+ name: "registry domain with port",
571+ input: "registry.example.com:5000/image:v1.0",
572+ expected: "registry.example.com:5000/image",
573+ },
559574 }
560575
561576 for _, tt := range tests {
@@ -556,6 +556,21 @@ func TestNormalizeImageRef(t *testing.T) {
556 input: "nginx:latest",556 input: "nginx:latest",
557 expected: "docker.io/library/nginx",557 expected: "docker.io/library/nginx",
558 },558 },
559+ {
560+ name: "localhost with port",
561+ input: "localhost:5000/myapp:latest",
562+ expected: "localhost:5000/myapp",
563+ },
564+ {
565+ name: "localhost with port no tag",
566+ input: "localhost:5000/myapp",
567+ expected: "localhost:5000/myapp",
568+ },
569+ {
570+ name: "registry domain with port",
571+ input: "registry.example.com:5000/image:v1.0",
572+ expected: "registry.example.com:5000/image",
573+ },
559 }574 }
560 575
561 for _, tt := range tests {576 for _, tt := range tests {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close