detached 9424b2e

fix(registry): keep fqdn tags for internal pulls

commit
9424b2e0b6bb2655063487ba759520179f456acd
author
bnema · · 2026-01-13 20:48 UTC
parent
8455ef6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +241 -16
M internal/adapters/out/docker/runtime.go +19 -0
@@ -425,6 +425,25 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
425425 return nil
426426 }
427427
428+// TagImage tags a locally available image with a new reference.
429+func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
430+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
431+ zerowrap.FieldLayer: "adapter",
432+ zerowrap.FieldAdapter: "docker",
433+ zerowrap.FieldAction: "TagImage",
434+ "source": sourceRef,
435+ "target": targetRef,
436+ })
437+ log := zerowrap.FromCtx(ctx)
438+
439+ if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {
440+ return log.WrapErr(err, "failed to tag image")
441+ }
442+
443+ log.Info().Msg("image tagged successfully")
444+ return nil
445+}
446+
428447 // RemoveImage removes an image.
429448 func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
430449 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -425,6 +425,25 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
425 return nil425 return nil
426 }426 }
427 427
428+// TagImage tags a locally available image with a new reference.
429+func (r *Runtime) TagImage(ctx context.Context, sourceRef, targetRef string) error {
430+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
431+ zerowrap.FieldLayer: "adapter",
432+ zerowrap.FieldAdapter: "docker",
433+ zerowrap.FieldAction: "TagImage",
434+ "source": sourceRef,
435+ "target": targetRef,
436+ })
437+ log := zerowrap.FromCtx(ctx)
438+
439+ if err := r.client.ImageTag(ctx, sourceRef, targetRef); err != nil {
440+ return log.WrapErr(err, "failed to tag image")
441+ }
442+
443+ log.Info().Msg("image tagged successfully")
444+ return nil
445+}
446+
428 // RemoveImage removes an image.447 // RemoveImage removes an image.
429 func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {448 func (r *Runtime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
430 ctx = zerowrap.CtxWithFields(ctx, map[string]any{449 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/boundaries/out/mocks/mock_container_runtime.go +63 -0
@@ -1892,6 +1892,69 @@ func (_c *MockContainerRuntime_StopContainer_Call) RunAndReturn(run func(ctx con
18921892 return _c
18931893 }
18941894
1895+// TagImage provides a mock function for the type MockContainerRuntime
1896+func (_mock *MockContainerRuntime) TagImage(ctx context.Context, sourceRef string, targetRef string) error {
1897+ ret := _mock.Called(ctx, sourceRef, targetRef)
1898+
1899+ if len(ret) == 0 {
1900+ panic("no return value specified for TagImage")
1901+ }
1902+
1903+ var r0 error
1904+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
1905+ r0 = returnFunc(ctx, sourceRef, targetRef)
1906+ } else {
1907+ r0 = ret.Error(0)
1908+ }
1909+ return r0
1910+}
1911+
1912+// MockContainerRuntime_TagImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TagImage'
1913+type MockContainerRuntime_TagImage_Call struct {
1914+ *mock.Call
1915+}
1916+
1917+// TagImage is a helper method to define mock.On call
1918+// - ctx context.Context
1919+// - sourceRef string
1920+// - targetRef string
1921+func (_e *MockContainerRuntime_Expecter) TagImage(ctx interface{}, sourceRef interface{}, targetRef interface{}) *MockContainerRuntime_TagImage_Call {
1922+ return &MockContainerRuntime_TagImage_Call{Call: _e.mock.On("TagImage", ctx, sourceRef, targetRef)}
1923+}
1924+
1925+func (_c *MockContainerRuntime_TagImage_Call) Run(run func(ctx context.Context, sourceRef string, targetRef string)) *MockContainerRuntime_TagImage_Call {
1926+ _c.Call.Run(func(args mock.Arguments) {
1927+ var arg0 context.Context
1928+ if args[0] != nil {
1929+ arg0 = args[0].(context.Context)
1930+ }
1931+ var arg1 string
1932+ if args[1] != nil {
1933+ arg1 = args[1].(string)
1934+ }
1935+ var arg2 string
1936+ if args[2] != nil {
1937+ arg2 = args[2].(string)
1938+ }
1939+ run(
1940+ arg0,
1941+ arg1,
1942+ arg2,
1943+ )
1944+ })
1945+ return _c
1946+}
1947+
1948+func (_c *MockContainerRuntime_TagImage_Call) Return(err error) *MockContainerRuntime_TagImage_Call {
1949+ _c.Call.Return(err)
1950+ return _c
1951+}
1952+
1953+func (_c *MockContainerRuntime_TagImage_Call) RunAndReturn(run func(ctx context.Context, sourceRef string, targetRef string) error) *MockContainerRuntime_TagImage_Call {
1954+ _c.Call.Return(run)
1955+ return _c
1956+}
1957+
18951958 // Version provides a mock function for the type MockContainerRuntime
18961959 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {
18971960 ret := _mock.Called(ctx)
@@ -1892,6 +1892,69 @@ func (_c *MockContainerRuntime_StopContainer_Call) RunAndReturn(run func(ctx con
1892 return _c1892 return _c
1893 }1893 }
1894 1894
1895+// TagImage provides a mock function for the type MockContainerRuntime
1896+func (_mock *MockContainerRuntime) TagImage(ctx context.Context, sourceRef string, targetRef string) error {
1897+ ret := _mock.Called(ctx, sourceRef, targetRef)
1898+
1899+ if len(ret) == 0 {
1900+ panic("no return value specified for TagImage")
1901+ }
1902+
1903+ var r0 error
1904+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
1905+ r0 = returnFunc(ctx, sourceRef, targetRef)
1906+ } else {
1907+ r0 = ret.Error(0)
1908+ }
1909+ return r0
1910+}
1911+
1912+// MockContainerRuntime_TagImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TagImage'
1913+type MockContainerRuntime_TagImage_Call struct {
1914+ *mock.Call
1915+}
1916+
1917+// TagImage is a helper method to define mock.On call
1918+// - ctx context.Context
1919+// - sourceRef string
1920+// - targetRef string
1921+func (_e *MockContainerRuntime_Expecter) TagImage(ctx interface{}, sourceRef interface{}, targetRef interface{}) *MockContainerRuntime_TagImage_Call {
1922+ return &MockContainerRuntime_TagImage_Call{Call: _e.mock.On("TagImage", ctx, sourceRef, targetRef)}
1923+}
1924+
1925+func (_c *MockContainerRuntime_TagImage_Call) Run(run func(ctx context.Context, sourceRef string, targetRef string)) *MockContainerRuntime_TagImage_Call {
1926+ _c.Call.Run(func(args mock.Arguments) {
1927+ var arg0 context.Context
1928+ if args[0] != nil {
1929+ arg0 = args[0].(context.Context)
1930+ }
1931+ var arg1 string
1932+ if args[1] != nil {
1933+ arg1 = args[1].(string)
1934+ }
1935+ var arg2 string
1936+ if args[2] != nil {
1937+ arg2 = args[2].(string)
1938+ }
1939+ run(
1940+ arg0,
1941+ arg1,
1942+ arg2,
1943+ )
1944+ })
1945+ return _c
1946+}
1947+
1948+func (_c *MockContainerRuntime_TagImage_Call) Return(err error) *MockContainerRuntime_TagImage_Call {
1949+ _c.Call.Return(err)
1950+ return _c
1951+}
1952+
1953+func (_c *MockContainerRuntime_TagImage_Call) RunAndReturn(run func(ctx context.Context, sourceRef string, targetRef string) error) *MockContainerRuntime_TagImage_Call {
1954+ _c.Call.Return(run)
1955+ return _c
1956+}
1957+
1895 // Version provides a mock function for the type MockContainerRuntime1958 // Version provides a mock function for the type MockContainerRuntime
1896 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {1959 func (_mock *MockContainerRuntime) Version(ctx context.Context) (string, error) {
1897 ret := _mock.Called(ctx)1960 ret := _mock.Called(ctx)
M internal/boundaries/out/runtime.go +1 -0
@@ -29,6 +29,7 @@ type ContainerRuntime interface {
2929 // Image operations
3030 PullImage(ctx context.Context, image string) error
3131 PullImageWithAuth(ctx context.Context, image, username, password string) error
32+ TagImage(ctx context.Context, sourceRef, targetRef string) error
3233 RemoveImage(ctx context.Context, image string, force bool) error
3334 ListImages(ctx context.Context) ([]string, error)
3435
@@ -29,6 +29,7 @@ type ContainerRuntime interface {
29 // Image operations29 // Image operations
30 PullImage(ctx context.Context, image string) error30 PullImage(ctx context.Context, image string) error
31 PullImageWithAuth(ctx context.Context, image, username, password string) error31 PullImageWithAuth(ctx context.Context, image, username, password string) error
32+ TagImage(ctx context.Context, sourceRef, targetRef string) error
32 RemoveImage(ctx context.Context, image string, force bool) error33 RemoveImage(ctx context.Context, image string, force bool) error
33 ListImages(ctx context.Context) ([]string, error)34 ListImages(ctx context.Context) ([]string, error)
34 35
M internal/usecase/container/events.go +19 -1
@@ -77,9 +77,13 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool {
7777 func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route {
7878 var routes []domain.Route
7979
80+ registryDomain := h.configSvc.GetRegistryDomain()
81+ normalizedImage := normalizeRegistryImage(imageName, registryDomain)
82+
8083 configuredRoutes := h.configSvc.GetRoutes(h.ctx)
8184 for _, route := range configuredRoutes {
82- if strings.EqualFold(route.Image, imageName) {
85+ normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain)
86+ if strings.EqualFold(normalizedRouteImage, normalizedImage) {
8387 routes = append(routes, route)
8488 }
8589 }
@@ -87,6 +91,20 @@ func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route
8791 return routes
8892 }
8993
94+func normalizeRegistryImage(imageName, registryDomain string) string {
95+ registryDomain = strings.TrimSuffix(registryDomain, "/")
96+ if registryDomain == "" {
97+ return imageName
98+ }
99+
100+ prefix := registryDomain + "/"
101+ if strings.HasPrefix(imageName, prefix) {
102+ return strings.TrimPrefix(imageName, prefix)
103+ }
104+
105+ return imageName
106+}
107+
90108 // ConfigReloadHandler handles config.reload events.
91109 type ConfigReloadHandler struct {
92110 containerSvc in.ContainerService
@@ -77,9 +77,13 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool {
77 func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route {77 func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route {
78 var routes []domain.Route78 var routes []domain.Route
79 79
80+ registryDomain := h.configSvc.GetRegistryDomain()
81+ normalizedImage := normalizeRegistryImage(imageName, registryDomain)
82+
80 configuredRoutes := h.configSvc.GetRoutes(h.ctx)83 configuredRoutes := h.configSvc.GetRoutes(h.ctx)
81 for _, route := range configuredRoutes {84 for _, route := range configuredRoutes {
82- if strings.EqualFold(route.Image, imageName) {85+ normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain)
86+ if strings.EqualFold(normalizedRouteImage, normalizedImage) {
83 routes = append(routes, route)87 routes = append(routes, route)
84 }88 }
85 }89 }
@@ -87,6 +91,20 @@ func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route
87 return routes91 return routes
88 }92 }
89 93
94+func normalizeRegistryImage(imageName, registryDomain string) string {
95+ registryDomain = strings.TrimSuffix(registryDomain, "/")
96+ if registryDomain == "" {
97+ return imageName
98+ }
99+
100+ prefix := registryDomain + "/"
101+ if strings.HasPrefix(imageName, prefix) {
102+ return strings.TrimPrefix(imageName, prefix)
103+ }
104+
105+ return imageName
106+}
107+
90 // ConfigReloadHandler handles config.reload events.108 // ConfigReloadHandler handles config.reload events.
91 type ConfigReloadHandler struct {109 type ConfigReloadHandler struct {
92 containerSvc in.ContainerService110 containerSvc in.ContainerService
M internal/usecase/container/events_test.go +36 -0
@@ -36,6 +36,8 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
3636
3737 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
3838
39+ configSvc.EXPECT().GetRegistryDomain().Return("")
40+
3941 // Configure routes that match the pushed image
4042 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
4143 {Domain: "app1.example.com", Image: "myapp:latest"},
@@ -72,6 +74,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
7274
7375 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
7476
77+ configSvc.EXPECT().GetRegistryDomain().Return("")
78+
7579 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
7680 {Domain: "other.example.com", Image: "otherapp:latest"},
7781 })
@@ -114,6 +118,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
114118
115119 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
116120
121+ configSvc.EXPECT().GetRegistryDomain().Return("")
122+
117123 // Image name "myapp" with empty tag should become "myapp:latest"
118124 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
119125 {Domain: "app.example.com", Image: "myapp:latest"},
@@ -142,6 +148,8 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
142148
143149 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
144150
151+ configSvc.EXPECT().GetRegistryDomain().Return("")
152+
145153 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
146154 {Domain: "app.example.com", Image: "myapp:latest"},
147155 })
@@ -161,6 +169,34 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
161169 assert.NoError(t, err)
162170 }
163171
172+func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
173+ containerSvc := inmocks.NewMockContainerService(t)
174+ configSvc := inmocks.NewMockConfigService(t)
175+
176+ handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
177+
178+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com")
179+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
180+ {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
181+ })
182+
183+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
184+ Domain: "app.example.com",
185+ Image: "registry.example.com/docker.io/library/nginx:latest",
186+ }).Return(&domain.Container{ID: "container-1"}, nil)
187+
188+ event := domain.Event{
189+ ID: "event-123",
190+ Type: domain.EventImagePushed,
191+ ImageName: "docker.io/library/nginx",
192+ Tag: "latest",
193+ }
194+
195+ err := handler.Handle(event)
196+
197+ assert.NoError(t, err)
198+}
199+
164200 // ConfigReloadHandler tests
165201
166202 func TestConfigReloadHandler_CanHandle(t *testing.T) {
@@ -36,6 +36,8 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) {
36 36
37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)37 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
38 38
39+ configSvc.EXPECT().GetRegistryDomain().Return("")
40+
39 // Configure routes that match the pushed image41 // Configure routes that match the pushed image
40 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{42 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
41 {Domain: "app1.example.com", Image: "myapp:latest"},43 {Domain: "app1.example.com", Image: "myapp:latest"},
@@ -72,6 +74,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) {
72 74
73 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)75 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
74 76
77+ configSvc.EXPECT().GetRegistryDomain().Return("")
78+
75 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{79 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
76 {Domain: "other.example.com", Image: "otherapp:latest"},80 {Domain: "other.example.com", Image: "otherapp:latest"},
77 })81 })
@@ -114,6 +118,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) {
114 118
115 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)119 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
116 120
121+ configSvc.EXPECT().GetRegistryDomain().Return("")
122+
117 // Image name "myapp" with empty tag should become "myapp:latest"123 // Image name "myapp" with empty tag should become "myapp:latest"
118 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{124 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
119 {Domain: "app.example.com", Image: "myapp:latest"},125 {Domain: "app.example.com", Image: "myapp:latest"},
@@ -142,6 +148,8 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
142 148
143 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)149 handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
144 150
151+ configSvc.EXPECT().GetRegistryDomain().Return("")
152+
145 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{153 configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
146 {Domain: "app.example.com", Image: "myapp:latest"},154 {Domain: "app.example.com", Image: "myapp:latest"},
147 })155 })
@@ -161,6 +169,34 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) {
161 assert.NoError(t, err)169 assert.NoError(t, err)
162 }170 }
163 171
172+func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) {
173+ containerSvc := inmocks.NewMockContainerService(t)
174+ configSvc := inmocks.NewMockConfigService(t)
175+
176+ handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc)
177+
178+ configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com")
179+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{
180+ {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"},
181+ })
182+
183+ containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{
184+ Domain: "app.example.com",
185+ Image: "registry.example.com/docker.io/library/nginx:latest",
186+ }).Return(&domain.Container{ID: "container-1"}, nil)
187+
188+ event := domain.Event{
189+ ID: "event-123",
190+ Type: domain.EventImagePushed,
191+ ImageName: "docker.io/library/nginx",
192+ Tag: "latest",
193+ }
194+
195+ err := handler.Handle(event)
196+
197+ assert.NoError(t, err)
198+}
199+
164 // ConfigReloadHandler tests200 // ConfigReloadHandler tests
165 201
166 func TestConfigReloadHandler_CanHandle(t *testing.T) {202 func TestConfigReloadHandler_CanHandle(t *testing.T) {
M internal/usecase/container/service.go +57 -15
@@ -88,8 +88,8 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
8888 log.WrapErr(err, "failed to cleanup orphaned containers")
8989 }
9090
91- // Build image reference and ensure it's available
92- // ensureImage returns the actual image reference used (may be rewritten for internal pulls)
91+ // Build image reference and ensure it's available.
92+ // ensureImage returns the canonical reference; internal pulls may use the local registry.
9393 imageRef := s.buildImageRef(route.Image)
9494 actualImageRef, err := s.ensureImage(ctx, imageRef)
9595 if err != nil {
@@ -469,33 +469,45 @@ func (s *Service) buildImageRef(image string) string {
469469 }
470470
471471 // ensureImage ensures the image is available locally, pulling if needed.
472-// Returns the actual image reference to use for container operations (may differ
473-// from input if localhost rewrite was applied for internal pulls).
472+// Returns the canonical image reference to use for container operations.
474473 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
475474 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
476475 log := zerowrap.FromCtx(ctx)
477476
478- // Determine if this is an internal deploy and what reference to use
477+ // Determine if this is an internal deploy and what reference to use for pulls.
479478 pullRef := imageRef
480479 isInternal := domain.IsInternalDeploy(ctx)
481- if isInternal && s.config.RegistryDomain != "" {
482- pullRef = rewriteToRegistryDomain(imageRef, s.config.RegistryDomain)
480+ if isInternal {
481+ pullRef = rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort)
482+ }
483+ if pullRef != imageRef {
483484 log.Info().
484485 Str("original_ref", imageRef).
485- Str("registry_ref", pullRef).
486- Msg("internal deploy: using registry domain for pull")
486+ Str("pull_ref", pullRef).
487+ Msg("internal deploy: using localhost registry for pull")
487488 }
488489
489- // Check local images using the reference we'll actually use
490+ // Check local images using the canonical reference or pull reference.
490491 localImages, err := s.runtime.ListImages(ctx)
491492 if err != nil {
492493 log.WrapErr(err, "failed to list local images, will attempt pull")
493494 } else {
494- normalizedRef := normalizeImageRef(pullRef)
495+ normalizedRef := normalizeImageRef(imageRef)
496+ normalizedPullRef := normalizeImageRef(pullRef)
495497 for _, img := range localImages {
496- if normalizeImageRef(img) == normalizedRef {
498+ normalizedImage := normalizeImageRef(img)
499+ if normalizedImage == normalizedRef {
500+ log.Info().Msg("image found locally, skipping pull")
501+ return imageRef, nil
502+ }
503+ if normalizedImage == normalizedPullRef {
504+ if pullRef != imageRef {
505+ if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil {
506+ return "", log.WrapErr(err, "failed to tag image from pull reference")
507+ }
508+ }
497509 log.Info().Msg("image found locally, skipping pull")
498- return pullRef, nil
510+ return imageRef, nil
499511 }
500512 }
501513 }
@@ -525,8 +537,14 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
525537 }
526538 }
527539
540+ if pullRef != imageRef {
541+ if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil {
542+ return "", log.WrapErr(err, "failed to tag image from pull reference")
543+ }
544+ }
545+
528546 log.Info().Msg("image pulled successfully")
529- return pullRef, nil
547+ return imageRef, nil
530548 }
531549
532550 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
@@ -726,7 +744,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
726744
727745 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
728746
729- // Ensure image (get actual image ref, may be rewritten for internal pulls)
747+ // Ensure image (canonical ref is returned; internal pulls may use the local registry).
730748 imageRef := s.buildImageRef(serviceImage)
731749 actualImageRef, err := s.ensureImage(ctx, imageRef)
732750 if err != nil {
@@ -993,3 +1011,27 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
9931011
9941012 return prefix + imageRef
9951013 }
1014+
1015+// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1016+// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1017+func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1018+ if imageRef == "" {
1019+ return imageRef
1020+ }
1021+
1022+ localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1023+ localPrefix := localRegistry + "/"
1024+ if strings.HasPrefix(imageRef, localPrefix) {
1025+ return imageRef
1026+ }
1027+
1028+ registryDomain = strings.TrimSuffix(registryDomain, "/")
1029+ if registryDomain != "" {
1030+ prefix := registryDomain + "/"
1031+ if strings.HasPrefix(imageRef, prefix) {
1032+ imageRef = strings.TrimPrefix(imageRef, prefix)
1033+ }
1034+ }
1035+
1036+ return localPrefix + imageRef
1037+}
@@ -88,8 +88,8 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
88 log.WrapErr(err, "failed to cleanup orphaned containers")88 log.WrapErr(err, "failed to cleanup orphaned containers")
89 }89 }
90 90
91- // Build image reference and ensure it's available91+ // Build image reference and ensure it's available.
92- // ensureImage returns the actual image reference used (may be rewritten for internal pulls)92+ // ensureImage returns the canonical reference; internal pulls may use the local registry.
93 imageRef := s.buildImageRef(route.Image)93 imageRef := s.buildImageRef(route.Image)
94 actualImageRef, err := s.ensureImage(ctx, imageRef)94 actualImageRef, err := s.ensureImage(ctx, imageRef)
95 if err != nil {95 if err != nil {
@@ -469,33 +469,45 @@ func (s *Service) buildImageRef(image string) string {
469 }469 }
470 470
471 // ensureImage ensures the image is available locally, pulling if needed.471 // ensureImage ensures the image is available locally, pulling if needed.
472-// Returns the actual image reference to use for container operations (may differ472+// Returns the canonical image reference to use for container operations.
473-// from input if localhost rewrite was applied for internal pulls).
474 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {473 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
475 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)474 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
476 log := zerowrap.FromCtx(ctx)475 log := zerowrap.FromCtx(ctx)
477 476
478- // Determine if this is an internal deploy and what reference to use477+ // Determine if this is an internal deploy and what reference to use for pulls.
479 pullRef := imageRef478 pullRef := imageRef
480 isInternal := domain.IsInternalDeploy(ctx)479 isInternal := domain.IsInternalDeploy(ctx)
481- if isInternal && s.config.RegistryDomain != "" {480+ if isInternal {
482- pullRef = rewriteToRegistryDomain(imageRef, s.config.RegistryDomain)481+ pullRef = rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort)
482+ }
483+ if pullRef != imageRef {
483 log.Info().484 log.Info().
484 Str("original_ref", imageRef).485 Str("original_ref", imageRef).
485- Str("registry_ref", pullRef).486+ Str("pull_ref", pullRef).
486- Msg("internal deploy: using registry domain for pull")487+ Msg("internal deploy: using localhost registry for pull")
487 }488 }
488 489
489- // Check local images using the reference we'll actually use490+ // Check local images using the canonical reference or pull reference.
490 localImages, err := s.runtime.ListImages(ctx)491 localImages, err := s.runtime.ListImages(ctx)
491 if err != nil {492 if err != nil {
492 log.WrapErr(err, "failed to list local images, will attempt pull")493 log.WrapErr(err, "failed to list local images, will attempt pull")
493 } else {494 } else {
494- normalizedRef := normalizeImageRef(pullRef)495+ normalizedRef := normalizeImageRef(imageRef)
496+ normalizedPullRef := normalizeImageRef(pullRef)
495 for _, img := range localImages {497 for _, img := range localImages {
496- if normalizeImageRef(img) == normalizedRef {498+ normalizedImage := normalizeImageRef(img)
499+ if normalizedImage == normalizedRef {
500+ log.Info().Msg("image found locally, skipping pull")
501+ return imageRef, nil
502+ }
503+ if normalizedImage == normalizedPullRef {
504+ if pullRef != imageRef {
505+ if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil {
506+ return "", log.WrapErr(err, "failed to tag image from pull reference")
507+ }
508+ }
497 log.Info().Msg("image found locally, skipping pull")509 log.Info().Msg("image found locally, skipping pull")
498- return pullRef, nil510+ return imageRef, nil
499 }511 }
500 }512 }
501 }513 }
@@ -525,8 +537,14 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
525 }537 }
526 }538 }
527 539
540+ if pullRef != imageRef {
541+ if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil {
542+ return "", log.WrapErr(err, "failed to tag image from pull reference")
543+ }
544+ }
545+
528 log.Info().Msg("image pulled successfully")546 log.Info().Msg("image pulled successfully")
529- return pullRef, nil547+ return imageRef, nil
530 }548 }
531 549
532 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {550 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
@@ -726,7 +744,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
726 744
727 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")745 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
728 746
729- // Ensure image (get actual image ref, may be rewritten for internal pulls)747+ // Ensure image (canonical ref is returned; internal pulls may use the local registry).
730 imageRef := s.buildImageRef(serviceImage)748 imageRef := s.buildImageRef(serviceImage)
731 actualImageRef, err := s.ensureImage(ctx, imageRef)749 actualImageRef, err := s.ensureImage(ctx, imageRef)
732 if err != nil {750 if err != nil {
@@ -993,3 +1011,27 @@ func rewriteToRegistryDomain(imageRef, registryDomain string) string {
993 1011
994 return prefix + imageRef1012 return prefix + imageRef
995 }1013 }
1014+
1015+// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1016+// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1017+func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1018+ if imageRef == "" {
1019+ return imageRef
1020+ }
1021+
1022+ localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1023+ localPrefix := localRegistry + "/"
1024+ if strings.HasPrefix(imageRef, localPrefix) {
1025+ return imageRef
1026+ }
1027+
1028+ registryDomain = strings.TrimSuffix(registryDomain, "/")
1029+ if registryDomain != "" {
1030+ prefix := registryDomain + "/"
1031+ if strings.HasPrefix(imageRef, prefix) {
1032+ imageRef = strings.TrimPrefix(imageRef, prefix)
1033+ }
1034+ }
1035+
1036+ return localPrefix + imageRef
1037+}
M internal/usecase/container/service_test.go +46 -0
@@ -650,3 +650,49 @@ func TestRewriteToRegistryDomain(t *testing.T) {
650650 })
651651 }
652652 }
653+
654+func TestRewriteToLocalRegistry(t *testing.T) {
655+ tests := []struct {
656+ name string
657+ imageRef string
658+ registryDomain string
659+ registryPort int
660+ wantRef string
661+ }{
662+ {
663+ name: "rewrites registry domain prefix",
664+ imageRef: "registry.example.com/myapp:latest",
665+ registryDomain: "registry.example.com",
666+ registryPort: 5000,
667+ wantRef: "localhost:5000/myapp:latest",
668+ },
669+ {
670+ name: "prefixes local registry when no domain",
671+ imageRef: "myapp:v1.0",
672+ registryDomain: "registry.example.com",
673+ registryPort: 5000,
674+ wantRef: "localhost:5000/myapp:v1.0",
675+ },
676+ {
677+ name: "keeps existing localhost prefix",
678+ imageRef: "localhost:5000/myapp:latest",
679+ registryDomain: "registry.example.com",
680+ registryPort: 5000,
681+ wantRef: "localhost:5000/myapp:latest",
682+ },
683+ {
684+ name: "prefixes external image path",
685+ imageRef: "docker.io/library/nginx:latest",
686+ registryDomain: "registry.example.com",
687+ registryPort: 5000,
688+ wantRef: "localhost:5000/docker.io/library/nginx:latest",
689+ },
690+ }
691+
692+ for _, tt := range tests {
693+ t.Run(tt.name, func(t *testing.T) {
694+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
695+ assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
696+ })
697+ }
698+}
@@ -650,3 +650,49 @@ func TestRewriteToRegistryDomain(t *testing.T) {
650 })650 })
651 }651 }
652 }652 }
653+
654+func TestRewriteToLocalRegistry(t *testing.T) {
655+ tests := []struct {
656+ name string
657+ imageRef string
658+ registryDomain string
659+ registryPort int
660+ wantRef string
661+ }{
662+ {
663+ name: "rewrites registry domain prefix",
664+ imageRef: "registry.example.com/myapp:latest",
665+ registryDomain: "registry.example.com",
666+ registryPort: 5000,
667+ wantRef: "localhost:5000/myapp:latest",
668+ },
669+ {
670+ name: "prefixes local registry when no domain",
671+ imageRef: "myapp:v1.0",
672+ registryDomain: "registry.example.com",
673+ registryPort: 5000,
674+ wantRef: "localhost:5000/myapp:v1.0",
675+ },
676+ {
677+ name: "keeps existing localhost prefix",
678+ imageRef: "localhost:5000/myapp:latest",
679+ registryDomain: "registry.example.com",
680+ registryPort: 5000,
681+ wantRef: "localhost:5000/myapp:latest",
682+ },
683+ {
684+ name: "prefixes external image path",
685+ imageRef: "docker.io/library/nginx:latest",
686+ registryDomain: "registry.example.com",
687+ registryPort: 5000,
688+ wantRef: "localhost:5000/docker.io/library/nginx:latest",
689+ },
690+ }
691+
692+ for _, tt := range tests {
693+ t.Run(tt.name, func(t *testing.T) {
694+ gotRef := rewriteToLocalRegistry(tt.imageRef, tt.registryDomain, tt.registryPort)
695+ assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
696+ })
697+ }
698+}

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