detached 49eef8a

feat(registry): internal deploy bypass for self-registry pulls

When Gordon deploys containers triggered by its own registry's
image.pushed events, it now bypasses external authentication by:

1. Using context to mark internal deploys (domain.WithInternalDeploy)
2. Rewriting image references to localhost for internal pulls
3. Allowing unauthenticated localhost requests in registry middleware

This follows clean architecture - the domain defines the concept,
event handlers set the flag, and the service/middleware act on it.
No string-matching heuristics that could be spoofed.

Also fixes PullImageWithAuth to include ServerAddress field and
use StdEncoding for base64 (required by Docker API).
commit
49eef8ad62943e4a4a9d2b624b6b0b7a94ea8ab8
author
brice · · 2026-01-13 18:58 UTC
parent
4259e72
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +253 -36
M go.mod +4 -4
@@ -6,20 +6,20 @@ require (
66 github.com/bnema/zerowrap v1.3.0
77 github.com/docker/docker v28.3.3+incompatible
88 github.com/docker/go-connections v0.5.0
9+ github.com/golang-jwt/jwt/v5 v5.3.0
910 github.com/google/uuid v1.6.0
1011 github.com/spf13/cobra v1.10.2
1112 github.com/spf13/viper v1.21.0
1213 github.com/stretchr/testify v1.11.1
14+ golang.org/x/crypto v0.47.0
15+ golang.org/x/term v0.39.0
16+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
1317 )
1418
1519 require (
1620 github.com/cespare/xxhash/v2 v2.3.0 // indirect
17- github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
1821 github.com/rs/zerolog v1.34.0 // indirect
1922 go.yaml.in/yaml/v3 v3.0.4 // indirect
20- golang.org/x/crypto v0.47.0 // indirect
21- golang.org/x/term v0.39.0 // indirect
22- gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
2323 )
2424
2525 require (
@@ -6,20 +6,20 @@ require (
6 github.com/bnema/zerowrap v1.3.06 github.com/bnema/zerowrap v1.3.0
7 github.com/docker/docker v28.3.3+incompatible7 github.com/docker/docker v28.3.3+incompatible
8 github.com/docker/go-connections v0.5.08 github.com/docker/go-connections v0.5.0
9+ github.com/golang-jwt/jwt/v5 v5.3.0
9 github.com/google/uuid v1.6.010 github.com/google/uuid v1.6.0
10 github.com/spf13/cobra v1.10.211 github.com/spf13/cobra v1.10.2
11 github.com/spf13/viper v1.21.012 github.com/spf13/viper v1.21.0
12 github.com/stretchr/testify v1.11.113 github.com/stretchr/testify v1.11.1
14+ golang.org/x/crypto v0.47.0
15+ golang.org/x/term v0.39.0
16+ gopkg.in/natefinch/lumberjack.v2 v2.2.1
13 )17 )
14 18
15 require (19 require (
16 github.com/cespare/xxhash/v2 v2.3.0 // indirect20 github.com/cespare/xxhash/v2 v2.3.0 // indirect
17- github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
18 github.com/rs/zerolog v1.34.0 // indirect21 github.com/rs/zerolog v1.34.0 // indirect
19 go.yaml.in/yaml/v3 v3.0.4 // indirect22 go.yaml.in/yaml/v3 v3.0.4 // indirect
20- golang.org/x/crypto v0.47.0 // indirect
21- golang.org/x/term v0.39.0 // indirect
22- gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
23 )23 )
24 24
25 require (25 require (
M go.sum +2 -2 generated file: collapsed
M internal/adapters/in/http/middleware/auth.go +27 -0
@@ -84,6 +84,19 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
8484 func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
8585 return func(next http.Handler) http.Handler {
8686 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+ // Allow localhost requests without auth (internal pulls from Gordon itself)
88+ if isLocalhostRequest(r) {
89+ log.Debug().
90+ Str(zerowrap.FieldLayer, "adapter").
91+ Str(zerowrap.FieldAdapter, "http").
92+ Str(zerowrap.FieldMethod, r.Method).
93+ Str(zerowrap.FieldPath, r.URL.Path).
94+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
95+ Msg("localhost request - skipping auth")
96+ next.ServeHTTP(w, r)
97+ return
98+ }
99+
87100 // Warn if not using TLS
88101 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
89102 log.Warn().
@@ -222,6 +235,20 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
222235 return true
223236 }
224237
238+// isLocalhostRequest checks if the request originates from localhost.
239+// This is used to allow Gordon to pull from its own registry without auth.
240+func isLocalhostRequest(r *http.Request) bool {
241+ host := r.RemoteAddr
242+ // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
243+ if strings.HasPrefix(host, "127.") ||
244+ strings.HasPrefix(host, "[::1]") ||
245+ strings.HasPrefix(host, "::1") ||
246+ strings.HasPrefix(host, "localhost") {
247+ return true
248+ }
249+ return false
250+}
251+
225252 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
226253 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
227254 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
@@ -84,6 +84,19 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string,
84 func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {84 func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler {
85 return func(next http.Handler) http.Handler {85 return func(next http.Handler) http.Handler {
86 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {86 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
87+ // Allow localhost requests without auth (internal pulls from Gordon itself)
88+ if isLocalhostRequest(r) {
89+ log.Debug().
90+ Str(zerowrap.FieldLayer, "adapter").
91+ Str(zerowrap.FieldAdapter, "http").
92+ Str(zerowrap.FieldMethod, r.Method).
93+ Str(zerowrap.FieldPath, r.URL.Path).
94+ Str(zerowrap.FieldClientIP, r.RemoteAddr).
95+ Msg("localhost request - skipping auth")
96+ next.ServeHTTP(w, r)
97+ return
98+ }
99+
87 // Warn if not using TLS100 // Warn if not using TLS
88 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {101 if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
89 log.Warn().102 log.Warn().
@@ -222,6 +235,20 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
222 return true235 return true
223 }236 }
224 237
238+// isLocalhostRequest checks if the request originates from localhost.
239+// This is used to allow Gordon to pull from its own registry without auth.
240+func isLocalhostRequest(r *http.Request) bool {
241+ host := r.RemoteAddr
242+ // RemoteAddr includes port, e.g., "127.0.0.1:12345" or "[::1]:12345"
243+ if strings.HasPrefix(host, "127.") ||
244+ strings.HasPrefix(host, "[::1]") ||
245+ strings.HasPrefix(host, "::1") ||
246+ strings.HasPrefix(host, "localhost") {
247+ return true
248+ }
249+ return false
250+}
251+
225 // sendUnauthorized sends an HTTP 401 response with appropriate headers.252 // sendUnauthorized sends an HTTP 401 response with appropriate headers.
226 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {253 func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) {
227 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")254 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
M internal/adapters/out/docker/runtime.go +18 -3
@@ -378,18 +378,33 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
378378
379379 log.Info().Msg("pulling image with authentication")
380380
381+ // Extract registry server address from image reference
382+ // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
383+ serverAddress := imageRef
384+ if idx := strings.Index(imageRef, "/"); idx > 0 {
385+ serverAddress = imageRef[:idx]
386+ }
387+
381388 // Create authentication configuration
382389 authConfig := registry.AuthConfig{
383- Username: username,
384- Password: password,
390+ Username: username,
391+ Password: password,
392+ ServerAddress: serverAddress,
385393 }
386394
387395 // Encode authentication to base64 JSON
396+ // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
397+ // may be more strict. Using StdEncoding for maximum compatibility.
388398 authConfigBytes, err := json.Marshal(authConfig)
389399 if err != nil {
390400 return log.WrapErr(err, "failed to marshal auth config")
391401 }
392- authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
402+ authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
403+
404+ log.Debug().
405+ Str("server_address", serverAddress).
406+ Str("auth_json", string(authConfigBytes)).
407+ Msg("auth config for pull")
393408
394409 // Pull with authentication
395410 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{
@@ -378,18 +378,33 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
378 378
379 log.Info().Msg("pulling image with authentication")379 log.Info().Msg("pulling image with authentication")
380 380
381+ // Extract registry server address from image reference
382+ // e.g., "registry.bnema.dev/image:tag" -> "registry.bnema.dev"
383+ serverAddress := imageRef
384+ if idx := strings.Index(imageRef, "/"); idx > 0 {
385+ serverAddress = imageRef[:idx]
386+ }
387+
381 // Create authentication configuration388 // Create authentication configuration
382 authConfig := registry.AuthConfig{389 authConfig := registry.AuthConfig{
383- Username: username,390+ Username: username,
384- Password: password,391+ Password: password,
392+ ServerAddress: serverAddress,
385 }393 }
386 394
387 // Encode authentication to base64 JSON395 // Encode authentication to base64 JSON
396+ // Note: Docker API accepts both StdEncoding and URLEncoding, but Podman
397+ // may be more strict. Using StdEncoding for maximum compatibility.
388 authConfigBytes, err := json.Marshal(authConfig)398 authConfigBytes, err := json.Marshal(authConfig)
389 if err != nil {399 if err != nil {
390 return log.WrapErr(err, "failed to marshal auth config")400 return log.WrapErr(err, "failed to marshal auth config")
391 }401 }
392- authStr := base64.URLEncoding.EncodeToString(authConfigBytes)402+ authStr := base64.StdEncoding.EncodeToString(authConfigBytes)
403+
404+ log.Debug().
405+ Str("server_address", serverAddress).
406+ Str("auth_json", string(authConfigBytes)).
407+ Msg("auth config for pull")
393 408
394 // Pull with authentication409 // Pull with authentication
395 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{410 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{
M internal/app/run.go +1 -0
@@ -479,6 +479,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
479479 containerConfig := container.Config{
480480 RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
481481 RegistryDomain: cfg.Server.RegistryDomain,
482+ RegistryPort: cfg.Server.RegistryPort,
482483 RegistryUsername: cfg.RegistryAuth.Username,
483484 RegistryPassword: cfg.RegistryAuth.Password,
484485 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
@@ -479,6 +479,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
479 containerConfig := container.Config{479 containerConfig := container.Config{
480 RegistryAuthEnabled: cfg.RegistryAuth.Enabled,480 RegistryAuthEnabled: cfg.RegistryAuth.Enabled,
481 RegistryDomain: cfg.Server.RegistryDomain,481 RegistryDomain: cfg.Server.RegistryDomain,
482+ RegistryPort: cfg.Server.RegistryPort,
482 RegistryUsername: cfg.RegistryAuth.Username,483 RegistryUsername: cfg.RegistryAuth.Username,
483 RegistryPassword: cfg.RegistryAuth.Password,484 RegistryPassword: cfg.RegistryAuth.Password,
484 VolumeAutoCreate: v.GetBool("volumes.auto_create"),485 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
M internal/domain/event.go +25 -1
@@ -1,6 +1,9 @@
11 package domain
22
3-import "time"
3+import (
4+ "context"
5+ "time"
6+)
47
58 // EventType defines the type of event that occurred.
69 type EventType string
@@ -51,3 +54,24 @@ type ConfigReloadPayload struct {
5154 RemovedRoutes []string
5255 UpdatedRoutes []string
5356 }
57+
58+// Context keys for domain-level concerns.
59+type contextKey string
60+
61+const (
62+ // ContextKeyInternalDeploy indicates the deployment is triggered internally
63+ // (e.g., from our own registry's image.pushed event) and should bypass
64+ // external authentication for image pulls.
65+ ContextKeyInternalDeploy contextKey = "internal_deploy"
66+)
67+
68+// IsInternalDeploy checks if the context indicates an internal deployment.
69+func IsInternalDeploy(ctx context.Context) bool {
70+ v, ok := ctx.Value(ContextKeyInternalDeploy).(bool)
71+ return ok && v
72+}
73+
74+// WithInternalDeploy returns a context marked as an internal deployment.
75+func WithInternalDeploy(ctx context.Context) context.Context {
76+ return context.WithValue(ctx, ContextKeyInternalDeploy, true)
77+}
@@ -1,6 +1,9 @@
1 package domain1 package domain
2 2
3-import "time"3+import (
4+ "context"
5+ "time"
6+)
4 7
5 // EventType defines the type of event that occurred.8 // EventType defines the type of event that occurred.
6 type EventType string9 type EventType string
@@ -51,3 +54,24 @@ type ConfigReloadPayload struct {
51 RemovedRoutes []string54 RemovedRoutes []string
52 UpdatedRoutes []string55 UpdatedRoutes []string
53 }56 }
57+
58+// Context keys for domain-level concerns.
59+type contextKey string
60+
61+const (
62+ // ContextKeyInternalDeploy indicates the deployment is triggered internally
63+ // (e.g., from our own registry's image.pushed event) and should bypass
64+ // external authentication for image pulls.
65+ ContextKeyInternalDeploy contextKey = "internal_deploy"
66+)
67+
68+// IsInternalDeploy checks if the context indicates an internal deployment.
69+func IsInternalDeploy(ctx context.Context) bool {
70+ v, ok := ctx.Value(ContextKeyInternalDeploy).(bool)
71+ return ok && v
72+}
73+
74+// WithInternalDeploy returns a context marked as an internal deployment.
75+func WithInternalDeploy(ctx context.Context) context.Context {
76+ return context.WithValue(ctx, ContextKeyInternalDeploy, true)
77+}
A internal/domain/event_test.go +58 -0
@@ -0,0 +1,58 @@
1+package domain
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestIsInternalDeploy(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ ctx context.Context
14+ expected bool
15+ }{
16+ {
17+ name: "returns false for plain context",
18+ ctx: context.Background(),
19+ expected: false,
20+ },
21+ {
22+ name: "returns true for internal deploy context",
23+ ctx: WithInternalDeploy(context.Background()),
24+ expected: true,
25+ },
26+ {
27+ name: "returns false for context with wrong type",
28+ ctx: context.WithValue(context.Background(), ContextKeyInternalDeploy, "true"),
29+ expected: false,
30+ },
31+ {
32+ name: "returns false for context with false value",
33+ ctx: context.WithValue(context.Background(), ContextKeyInternalDeploy, false),
34+ expected: false,
35+ },
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ result := IsInternalDeploy(tt.ctx)
41+ assert.Equal(t, tt.expected, result)
42+ })
43+ }
44+}
45+
46+func TestWithInternalDeploy(t *testing.T) {
47+ ctx := context.Background()
48+
49+ // Before marking
50+ assert.False(t, IsInternalDeploy(ctx))
51+
52+ // After marking
53+ internalCtx := WithInternalDeploy(ctx)
54+ assert.True(t, IsInternalDeploy(internalCtx))
55+
56+ // Original context unchanged
57+ assert.False(t, IsInternalDeploy(ctx))
58+}
@@ -0,0 +1,58 @@
1+package domain
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestIsInternalDeploy(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ ctx context.Context
14+ expected bool
15+ }{
16+ {
17+ name: "returns false for plain context",
18+ ctx: context.Background(),
19+ expected: false,
20+ },
21+ {
22+ name: "returns true for internal deploy context",
23+ ctx: WithInternalDeploy(context.Background()),
24+ expected: true,
25+ },
26+ {
27+ name: "returns false for context with wrong type",
28+ ctx: context.WithValue(context.Background(), ContextKeyInternalDeploy, "true"),
29+ expected: false,
30+ },
31+ {
32+ name: "returns false for context with false value",
33+ ctx: context.WithValue(context.Background(), ContextKeyInternalDeploy, false),
34+ expected: false,
35+ },
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ result := IsInternalDeploy(tt.ctx)
41+ assert.Equal(t, tt.expected, result)
42+ })
43+ }
44+}
45+
46+func TestWithInternalDeploy(t *testing.T) {
47+ ctx := context.Background()
48+
49+ // Before marking
50+ assert.False(t, IsInternalDeploy(ctx))
51+
52+ // After marking
53+ internalCtx := WithInternalDeploy(ctx)
54+ assert.True(t, IsInternalDeploy(internalCtx))
55+
56+ // Original context unchanged
57+ assert.False(t, IsInternalDeploy(ctx))
58+}
M internal/usecase/container/events.go +5 -1
@@ -56,8 +56,12 @@ func (h *ImagePushedHandler) Handle(event domain.Event) error {
5656 return nil
5757 }
5858
59+ // Mark context as internal deploy - the event originated from our own registry,
60+ // so we can bypass external auth when pulling images (use localhost instead).
61+ internalCtx := domain.WithInternalDeploy(ctx)
62+
5963 for _, route := range routes {
60- if _, err := h.containerSvc.Deploy(ctx, route); err != nil {
64+ if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
6165 log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
6266 }
6367 }
@@ -56,8 +56,12 @@ func (h *ImagePushedHandler) Handle(event domain.Event) error {
56 return nil56 return nil
57 }57 }
58 58
59+ // Mark context as internal deploy - the event originated from our own registry,
60+ // so we can bypass external auth when pulling images (use localhost instead).
61+ internalCtx := domain.WithInternalDeploy(ctx)
62+
59 for _, route := range routes {63 for _, route := range routes {
60- if _, err := h.containerSvc.Deploy(ctx, route); err != nil {64+ if _, err := h.containerSvc.Deploy(internalCtx, route); err != nil {
61 log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})65 log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain})
62 }66 }
63 }67 }
M internal/usecase/container/service.go +60 -25
@@ -20,6 +20,7 @@ import (
2020 type Config struct {
2121 RegistryAuthEnabled bool
2222 RegistryDomain string
23+ RegistryPort int
2324 RegistryUsername string
2425 RegistryPassword string
2526 VolumeAutoCreate bool
@@ -86,8 +87,10 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
8687 }
8788
8889 // Build image reference and ensure it's available
90+ // ensureImage returns the actual image reference used (may be rewritten for internal pulls)
8991 imageRef := s.buildImageRef(route.Image)
90- if err := s.ensureImage(ctx, imageRef); err != nil {
92+ actualImageRef, err := s.ensureImage(ctx, imageRef)
93+ if err != nil {
9194 return nil, err
9295 }
9396
@@ -102,21 +105,21 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
102105 log.WrapErr(err, "failed to deploy some attachments")
103106 }
104107
105- // Get exposed ports
106- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
108+ // Get exposed ports (use actual image ref from pull)
109+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
107110 if err != nil {
108111 log.WrapErr(err, "failed to get exposed ports, using defaults")
109112 exposedPorts = []int{80, 8080, 3000}
110113 }
111114
112115 // Load environment
113- envVars, err := s.loadEnvironment(ctx, route.Domain, imageRef)
116+ envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
114117 if err != nil {
115118 return nil, err
116119 }
117120
118- // Setup volumes
119- volumes, err := s.setupVolumes(ctx, route.Domain, imageRef)
121+ // Setup volumes (use actual image ref from pull)
122+ volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
120123 if err != nil {
121124 return nil, err
122125 }
@@ -127,9 +130,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
127130 containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
128131 }
129132
130- // Create container
133+ // Create container (use actual image ref from pull, but track original in labels)
131134 containerConfig := &domain.ContainerConfig{
132- Image: imageRef,
135+ Image: actualImageRef,
133136 Name: containerName,
134137 Ports: exposedPorts,
135138 Env: envVars,
@@ -463,20 +466,34 @@ func (s *Service) buildImageRef(image string) string {
463466 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
464467 }
465468
466-func (s *Service) ensureImage(ctx context.Context, imageRef string) error {
469+// ensureImage ensures the image is available locally, pulling if needed.
470+// Returns the actual image reference to use for container operations (may differ
471+// from input if localhost rewrite was applied for internal pulls).
472+func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
467473 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
468474 log := zerowrap.FromCtx(ctx)
469475
470- // Check local images
476+ // Determine if this is an internal deploy and what reference to use
477+ pullRef := imageRef
478+ isInternal := domain.IsInternalDeploy(ctx)
479+ if isInternal && s.config.RegistryPort > 0 {
480+ pullRef = rewriteToLocalhost(imageRef, s.config.RegistryDomain, s.config.RegistryPort)
481+ log.Info().
482+ Str("original_ref", imageRef).
483+ Str("local_ref", pullRef).
484+ Msg("internal deploy: using localhost for pull")
485+ }
486+
487+ // Check local images using the reference we'll actually use
471488 localImages, err := s.runtime.ListImages(ctx)
472489 if err != nil {
473490 log.WrapErr(err, "failed to list local images, will attempt pull")
474491 } else {
475- normalizedRef := normalizeImageRef(imageRef)
492+ normalizedRef := normalizeImageRef(pullRef)
476493 for _, img := range localImages {
477494 if normalizeImageRef(img) == normalizedRef {
478495 log.Info().Msg("image found locally, skipping pull")
479- return nil
496+ return pullRef, nil
480497 }
481498 }
482499 }
@@ -484,18 +501,18 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) error {
484501 // Pull image
485502 log.Info().Msg("pulling image from registry")
486503
487- if s.config.RegistryAuthEnabled {
488- if err := s.runtime.PullImageWithAuth(ctx, imageRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
489- return log.WrapErr(err, "failed to pull image with auth")
504+ if s.config.RegistryAuthEnabled && !isInternal {
505+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
506+ return "", log.WrapErr(err, "failed to pull image with auth")
490507 }
491508 } else {
492- if err := s.runtime.PullImage(ctx, imageRef); err != nil {
493- return log.WrapErr(err, "failed to pull image")
509+ if err := s.runtime.PullImage(ctx, pullRef); err != nil {
510+ return "", log.WrapErr(err, "failed to pull image")
494511 }
495512 }
496513
497514 log.Info().Msg("image pulled successfully")
498- return nil
515+ return pullRef, nil
499516 }
500517
501518 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
@@ -695,36 +712,37 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
695712
696713 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
697714
698- // Ensure image
715+ // Ensure image (get actual image ref, may be rewritten for internal pulls)
699716 imageRef := s.buildImageRef(serviceImage)
700- if err := s.ensureImage(ctx, imageRef); err != nil {
717+ actualImageRef, err := s.ensureImage(ctx, imageRef)
718+ if err != nil {
701719 return err
702720 }
703721
704722 // Get image metadata
705- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)
723+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
706724 if err != nil {
707725 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
708726 exposedPorts = []int{}
709727 }
710728
711729 // Setup volumes (attachments need persistent data)
712- volumes, err := s.setupVolumes(ctx, containerName, imageRef)
730+ volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
713731 if err != nil {
714732 log.WrapErr(err, "failed to setup volumes for attachment")
715733 volumes = make(map[string]string)
716734 }
717735
718736 // Load environment (attachment-specific env file)
719- envVars, err := s.loadEnvironment(ctx, containerName, imageRef)
737+ envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
720738 if err != nil {
721739 log.WrapErr(err, "failed to load environment for attachment")
722740 envVars = []string{}
723741 }
724742
725- // Create container with attachment labels
743+ // Create container with attachment labels (use actual ref, track original in labels)
726744 config := &domain.ContainerConfig{
727- Image: imageRef,
745+ Image: actualImageRef,
728746 Name: containerName,
729747 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
730748 Ports: exposedPorts,
@@ -946,3 +964,20 @@ func (s *Service) publishContainerDeployed(ctx context.Context, domainName, cont
946964 log.Warn().Err(err).Msg("failed to publish container deployed event")
947965 }
948966 }
967+
968+// rewriteToLocalhost rewrites an image reference to use localhost for internal pulls.
969+// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
970+func rewriteToLocalhost(imageRef, registryDomain string, registryPort int) string {
971+ if registryDomain == "" {
972+ return imageRef
973+ }
974+
975+ // Strip the registry domain prefix if present
976+ prefix := registryDomain + "/"
977+ imagePath := imageRef
978+ if strings.HasPrefix(imageRef, prefix) {
979+ imagePath = strings.TrimPrefix(imageRef, prefix)
980+ }
981+
982+ return fmt.Sprintf("localhost:%d/%s", registryPort, imagePath)
983+}
@@ -20,6 +20,7 @@ import (
20 type Config struct {20 type Config struct {
21 RegistryAuthEnabled bool21 RegistryAuthEnabled bool
22 RegistryDomain string22 RegistryDomain string
23+ RegistryPort int
23 RegistryUsername string24 RegistryUsername string
24 RegistryPassword string25 RegistryPassword string
25 VolumeAutoCreate bool26 VolumeAutoCreate bool
@@ -86,8 +87,10 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
86 }87 }
87 88
88 // Build image reference and ensure it's available89 // Build image reference and ensure it's available
90+ // ensureImage returns the actual image reference used (may be rewritten for internal pulls)
89 imageRef := s.buildImageRef(route.Image)91 imageRef := s.buildImageRef(route.Image)
90- if err := s.ensureImage(ctx, imageRef); err != nil {92+ actualImageRef, err := s.ensureImage(ctx, imageRef)
93+ if err != nil {
91 return nil, err94 return nil, err
92 }95 }
93 96
@@ -102,21 +105,21 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
102 log.WrapErr(err, "failed to deploy some attachments")105 log.WrapErr(err, "failed to deploy some attachments")
103 }106 }
104 107
105- // Get exposed ports108+ // Get exposed ports (use actual image ref from pull)
106- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)109+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
107 if err != nil {110 if err != nil {
108 log.WrapErr(err, "failed to get exposed ports, using defaults")111 log.WrapErr(err, "failed to get exposed ports, using defaults")
109 exposedPorts = []int{80, 8080, 3000}112 exposedPorts = []int{80, 8080, 3000}
110 }113 }
111 114
112 // Load environment115 // Load environment
113- envVars, err := s.loadEnvironment(ctx, route.Domain, imageRef)116+ envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
114 if err != nil {117 if err != nil {
115 return nil, err118 return nil, err
116 }119 }
117 120
118- // Setup volumes121+ // Setup volumes (use actual image ref from pull)
119- volumes, err := s.setupVolumes(ctx, route.Domain, imageRef)122+ volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
120 if err != nil {123 if err != nil {
121 return nil, err124 return nil, err
122 }125 }
@@ -127,9 +130,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
127 containerName = fmt.Sprintf("gordon-%s-new", route.Domain)130 containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
128 }131 }
129 132
130- // Create container133+ // Create container (use actual image ref from pull, but track original in labels)
131 containerConfig := &domain.ContainerConfig{134 containerConfig := &domain.ContainerConfig{
132- Image: imageRef,135+ Image: actualImageRef,
133 Name: containerName,136 Name: containerName,
134 Ports: exposedPorts,137 Ports: exposedPorts,
135 Env: envVars,138 Env: envVars,
@@ -463,20 +466,34 @@ func (s *Service) buildImageRef(image string) string {
463 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)466 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
464 }467 }
465 468
466-func (s *Service) ensureImage(ctx context.Context, imageRef string) error {469+// ensureImage ensures the image is available locally, pulling if needed.
470+// Returns the actual image reference to use for container operations (may differ
471+// from input if localhost rewrite was applied for internal pulls).
472+func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
467 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)473 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
468 log := zerowrap.FromCtx(ctx)474 log := zerowrap.FromCtx(ctx)
469 475
470- // Check local images476+ // Determine if this is an internal deploy and what reference to use
477+ pullRef := imageRef
478+ isInternal := domain.IsInternalDeploy(ctx)
479+ if isInternal && s.config.RegistryPort > 0 {
480+ pullRef = rewriteToLocalhost(imageRef, s.config.RegistryDomain, s.config.RegistryPort)
481+ log.Info().
482+ Str("original_ref", imageRef).
483+ Str("local_ref", pullRef).
484+ Msg("internal deploy: using localhost for pull")
485+ }
486+
487+ // Check local images using the reference we'll actually use
471 localImages, err := s.runtime.ListImages(ctx)488 localImages, err := s.runtime.ListImages(ctx)
472 if err != nil {489 if err != nil {
473 log.WrapErr(err, "failed to list local images, will attempt pull")490 log.WrapErr(err, "failed to list local images, will attempt pull")
474 } else {491 } else {
475- normalizedRef := normalizeImageRef(imageRef)492+ normalizedRef := normalizeImageRef(pullRef)
476 for _, img := range localImages {493 for _, img := range localImages {
477 if normalizeImageRef(img) == normalizedRef {494 if normalizeImageRef(img) == normalizedRef {
478 log.Info().Msg("image found locally, skipping pull")495 log.Info().Msg("image found locally, skipping pull")
479- return nil496+ return pullRef, nil
480 }497 }
481 }498 }
482 }499 }
@@ -484,18 +501,18 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) error {
484 // Pull image501 // Pull image
485 log.Info().Msg("pulling image from registry")502 log.Info().Msg("pulling image from registry")
486 503
487- if s.config.RegistryAuthEnabled {504+ if s.config.RegistryAuthEnabled && !isInternal {
488- if err := s.runtime.PullImageWithAuth(ctx, imageRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {505+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
489- return log.WrapErr(err, "failed to pull image with auth")506+ return "", log.WrapErr(err, "failed to pull image with auth")
490 }507 }
491 } else {508 } else {
492- if err := s.runtime.PullImage(ctx, imageRef); err != nil {509+ if err := s.runtime.PullImage(ctx, pullRef); err != nil {
493- return log.WrapErr(err, "failed to pull image")510+ return "", log.WrapErr(err, "failed to pull image")
494 }511 }
495 }512 }
496 513
497 log.Info().Msg("image pulled successfully")514 log.Info().Msg("image pulled successfully")
498- return nil515+ return pullRef, nil
499 }516 }
500 517
501 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {518 func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
@@ -695,36 +712,37 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
695 712
696 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")713 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
697 714
698- // Ensure image715+ // Ensure image (get actual image ref, may be rewritten for internal pulls)
699 imageRef := s.buildImageRef(serviceImage)716 imageRef := s.buildImageRef(serviceImage)
700- if err := s.ensureImage(ctx, imageRef); err != nil {717+ actualImageRef, err := s.ensureImage(ctx, imageRef)
718+ if err != nil {
701 return err719 return err
702 }720 }
703 721
704 // Get image metadata722 // Get image metadata
705- exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef)723+ exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
706 if err != nil {724 if err != nil {
707 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")725 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
708 exposedPorts = []int{}726 exposedPorts = []int{}
709 }727 }
710 728
711 // Setup volumes (attachments need persistent data)729 // Setup volumes (attachments need persistent data)
712- volumes, err := s.setupVolumes(ctx, containerName, imageRef)730+ volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
713 if err != nil {731 if err != nil {
714 log.WrapErr(err, "failed to setup volumes for attachment")732 log.WrapErr(err, "failed to setup volumes for attachment")
715 volumes = make(map[string]string)733 volumes = make(map[string]string)
716 }734 }
717 735
718 // Load environment (attachment-specific env file)736 // Load environment (attachment-specific env file)
719- envVars, err := s.loadEnvironment(ctx, containerName, imageRef)737+ envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
720 if err != nil {738 if err != nil {
721 log.WrapErr(err, "failed to load environment for attachment")739 log.WrapErr(err, "failed to load environment for attachment")
722 envVars = []string{}740 envVars = []string{}
723 }741 }
724 742
725- // Create container with attachment labels743+ // Create container with attachment labels (use actual ref, track original in labels)
726 config := &domain.ContainerConfig{744 config := &domain.ContainerConfig{
727- Image: imageRef,745+ Image: actualImageRef,
728 Name: containerName,746 Name: containerName,
729 Hostname: serviceName, // Internal DNS: postgres, redis, etc.747 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
730 Ports: exposedPorts,748 Ports: exposedPorts,
@@ -946,3 +964,20 @@ func (s *Service) publishContainerDeployed(ctx context.Context, domainName, cont
946 log.Warn().Err(err).Msg("failed to publish container deployed event")964 log.Warn().Err(err).Msg("failed to publish container deployed event")
947 }965 }
948 }966 }
967+
968+// rewriteToLocalhost rewrites an image reference to use localhost for internal pulls.
969+// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
970+func rewriteToLocalhost(imageRef, registryDomain string, registryPort int) string {
971+ if registryDomain == "" {
972+ return imageRef
973+ }
974+
975+ // Strip the registry domain prefix if present
976+ prefix := registryDomain + "/"
977+ imagePath := imageRef
978+ if strings.HasPrefix(imageRef, prefix) {
979+ imagePath = strings.TrimPrefix(imageRef, prefix)
980+ }
981+
982+ return fmt.Sprintf("localhost:%d/%s", registryPort, imagePath)
983+}
M internal/usecase/container/service_test.go +53 -0
@@ -609,3 +609,56 @@ func TestMergeEnvironmentVariables(t *testing.T) {
609609 assert.Contains(t, result, "BAR=docker")
610610 assert.Contains(t, result, "BAZ=user")
611611 }
612+
613+func TestRewriteToLocalhost(t *testing.T) {
614+ tests := []struct {
615+ name string
616+ imageRef string
617+ registryDomain string
618+ registryPort int
619+ wantRef string
620+ }{
621+ {
622+ name: "rewrites registry domain to localhost",
623+ imageRef: "registry.example.com/myapp:latest",
624+ registryDomain: "registry.example.com",
625+ registryPort: 5000,
626+ wantRef: "localhost:5000/myapp:latest",
627+ },
628+ {
629+ name: "rewrites with nested path",
630+ imageRef: "registry.example.com/org/myapp:v1.0",
631+ registryDomain: "registry.example.com",
632+ registryPort: 5000,
633+ wantRef: "localhost:5000/org/myapp:v1.0",
634+ },
635+ {
636+ name: "rewrites external image to localhost with port",
637+ imageRef: "docker.io/library/nginx:latest",
638+ registryDomain: "registry.example.com",
639+ registryPort: 5000,
640+ wantRef: "localhost:5000/docker.io/library/nginx:latest",
641+ },
642+ {
643+ name: "empty registry domain returns original",
644+ imageRef: "registry.example.com/myapp:latest",
645+ registryDomain: "",
646+ registryPort: 5000,
647+ wantRef: "registry.example.com/myapp:latest",
648+ },
649+ {
650+ name: "custom port in config",
651+ imageRef: "registry.example.com/myapp:latest",
652+ registryDomain: "registry.example.com",
653+ registryPort: 5001,
654+ wantRef: "localhost:5001/myapp:latest",
655+ },
656+ }
657+
658+ for _, tt := range tests {
659+ t.Run(tt.name, func(t *testing.T) {
660+ gotRef := rewriteToLocalhost(tt.imageRef, tt.registryDomain, tt.registryPort)
661+ assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
662+ })
663+ }
664+}
@@ -609,3 +609,56 @@ func TestMergeEnvironmentVariables(t *testing.T) {
609 assert.Contains(t, result, "BAR=docker")609 assert.Contains(t, result, "BAR=docker")
610 assert.Contains(t, result, "BAZ=user")610 assert.Contains(t, result, "BAZ=user")
611 }611 }
612+
613+func TestRewriteToLocalhost(t *testing.T) {
614+ tests := []struct {
615+ name string
616+ imageRef string
617+ registryDomain string
618+ registryPort int
619+ wantRef string
620+ }{
621+ {
622+ name: "rewrites registry domain to localhost",
623+ imageRef: "registry.example.com/myapp:latest",
624+ registryDomain: "registry.example.com",
625+ registryPort: 5000,
626+ wantRef: "localhost:5000/myapp:latest",
627+ },
628+ {
629+ name: "rewrites with nested path",
630+ imageRef: "registry.example.com/org/myapp:v1.0",
631+ registryDomain: "registry.example.com",
632+ registryPort: 5000,
633+ wantRef: "localhost:5000/org/myapp:v1.0",
634+ },
635+ {
636+ name: "rewrites external image to localhost with port",
637+ imageRef: "docker.io/library/nginx:latest",
638+ registryDomain: "registry.example.com",
639+ registryPort: 5000,
640+ wantRef: "localhost:5000/docker.io/library/nginx:latest",
641+ },
642+ {
643+ name: "empty registry domain returns original",
644+ imageRef: "registry.example.com/myapp:latest",
645+ registryDomain: "",
646+ registryPort: 5000,
647+ wantRef: "registry.example.com/myapp:latest",
648+ },
649+ {
650+ name: "custom port in config",
651+ imageRef: "registry.example.com/myapp:latest",
652+ registryDomain: "registry.example.com",
653+ registryPort: 5001,
654+ wantRef: "localhost:5001/myapp:latest",
655+ },
656+ }
657+
658+ for _, tt := range tests {
659+ t.Run(tt.name, func(t *testing.T) {
660+ gotRef := rewriteToLocalhost(tt.imageRef, tt.registryDomain, tt.registryPort)
661+ assert.Equal(t, tt.wantRef, gotRef, "unexpected rewritten reference")
662+ })
663+ }
664+}

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