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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +253 -36
M go.mod +4 -4
| @@ -6,20 +6,20 @@ require ( | |||
| 6 | 6 | github.com/bnema/zerowrap v1.3.0 | |
| 7 | 7 | github.com/docker/docker v28.3.3+incompatible | |
| 8 | 8 | github.com/docker/go-connections v0.5.0 | |
| 9 | + | github.com/golang-jwt/jwt/v5 v5.3.0 | |
| 9 | 10 | github.com/google/uuid v1.6.0 | |
| 10 | 11 | github.com/spf13/cobra v1.10.2 | |
| 11 | 12 | github.com/spf13/viper v1.21.0 | |
| 12 | 13 | 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 | 19 | require ( | |
| 16 | 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 17 | - | github.com/golang-jwt/jwt/v5 v5.3.0 // indirect | |
| 18 | 21 | github.com/rs/zerolog v1.34.0 // indirect | |
| 19 | 22 | 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 | 25 | require ( | |
| @@ -6,20 +6,20 @@ require ( | |||||
| 6 | github.com/bnema/zerowrap v1.3.0 | 6 | github.com/bnema/zerowrap v1.3.0 | ||
| 7 | github.com/docker/docker v28.3.3+incompatible | 7 | github.com/docker/docker v28.3.3+incompatible | ||
| 8 | github.com/docker/go-connections v0.5.0 | 8 | 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.0 | 10 | github.com/google/uuid v1.6.0 | ||
| 10 | github.com/spf13/cobra v1.10.2 | 11 | github.com/spf13/cobra v1.10.2 | ||
| 11 | github.com/spf13/viper v1.21.0 | 12 | github.com/spf13/viper v1.21.0 | ||
| 12 | github.com/stretchr/testify v1.11.1 | 13 | 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 // indirect | 20 | 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 // indirect | 21 | github.com/rs/zerolog v1.34.0 // indirect | ||
| 19 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 22 | 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
| @@ -152,8 +152,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn | |||
| 152 | 152 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | |
| 153 | 153 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | |
| 154 | 154 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | |
| 155 | - | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= | |
| 156 | - | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= | |
| 155 | + | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= | |
| 156 | + | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= | |
| 157 | 157 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 158 | 158 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| 159 | 159 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | |
| @@ -152,8 +152,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn | |||||
| 152 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | 152 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||
| 153 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | 153 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||
| 154 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | 154 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= | ||
| 155 | - | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= | 155 | + | golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= |
| 156 | - | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= | 156 | + | golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= |
| 157 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 157 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 158 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 158 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
| 159 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | 159 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||
M internal/adapters/in/http/middleware/auth.go +27 -0
| @@ -84,6 +84,19 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string, | |||
| 84 | 84 | func RegistryAuthV2(authSvc in.AuthService, log zerowrap.Logger) func(http.Handler) http.Handler { | |
| 85 | 85 | return func(next http.Handler) http.Handler { | |
| 86 | 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 | 100 | // Warn if not using TLS | |
| 88 | 101 | if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" { | |
| 89 | 102 | log.Warn(). | |
| @@ -222,6 +235,20 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ | |||
| 222 | 235 | 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 | 252 | // sendUnauthorized sends an HTTP 401 response with appropriate headers. | |
| 226 | 253 | func sendUnauthorized(w http.ResponseWriter, authType domain.AuthType, host string, log zerowrap.Logger, r *http.Request) { | |
| 227 | 254 | 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 TLS | 100 | // 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 true | 235 | 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 | |||
| 378 | 378 | ||
| 379 | 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 | 388 | // Create authentication configuration | |
| 382 | 389 | authConfig := registry.AuthConfig{ | |
| 383 | - | Username: username, | |
| 384 | - | Password: password, | |
| 390 | + | Username: username, | |
| 391 | + | Password: password, | |
| 392 | + | ServerAddress: serverAddress, | |
| 385 | 393 | } | |
| 386 | 394 | ||
| 387 | 395 | // 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 | 398 | authConfigBytes, err := json.Marshal(authConfig) | |
| 389 | 399 | if err != nil { | |
| 390 | 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 | 409 | // Pull with authentication | |
| 395 | 410 | 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 configuration | 388 | // 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 JSON | 395 | // 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 authentication | 409 | // 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 | |||
| 479 | 479 | containerConfig := container.Config{ | |
| 480 | 480 | RegistryAuthEnabled: cfg.RegistryAuth.Enabled, | |
| 481 | 481 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 482 | + | RegistryPort: cfg.Server.RegistryPort, | |
| 482 | 483 | RegistryUsername: cfg.RegistryAuth.Username, | |
| 483 | 484 | RegistryPassword: cfg.RegistryAuth.Password, | |
| 484 | 485 | 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 @@ | |||
| 1 | 1 | package domain | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | // EventType defines the type of event that occurred. | |
| 6 | 9 | type EventType string | |
| @@ -51,3 +54,24 @@ type ConfigReloadPayload struct { | |||
| 51 | 54 | RemovedRoutes []string | |
| 52 | 55 | 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 | + | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package domain | 1 | 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 string | 9 | type EventType string | ||
| @@ -51,3 +54,24 @@ type ConfigReloadPayload struct { | |||||
| 51 | RemovedRoutes []string | 54 | RemovedRoutes []string | ||
| 52 | UpdatedRoutes []string | 55 | 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 { | |||
| 56 | 56 | 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 | 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 | 65 | log.WrapErrWithFields(err, "failed to deploy container for route", map[string]any{"domain": route.Domain}) | |
| 62 | 66 | } | |
| 63 | 67 | } | |
| @@ -56,8 +56,12 @@ func (h *ImagePushedHandler) Handle(event domain.Event) error { | |||||
| 56 | return nil | 56 | 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 ( | |||
| 20 | 20 | type Config struct { | |
| 21 | 21 | RegistryAuthEnabled bool | |
| 22 | 22 | RegistryDomain string | |
| 23 | + | RegistryPort int | |
| 23 | 24 | RegistryUsername string | |
| 24 | 25 | RegistryPassword string | |
| 25 | 26 | VolumeAutoCreate bool | |
| @@ -86,8 +87,10 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // Build image reference and ensure it's available | |
| 90 | + | // ensureImage returns the actual image reference used (may be rewritten for internal pulls) | |
| 89 | 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 | 94 | return nil, err | |
| 92 | 95 | } | |
| 93 | 96 | ||
| @@ -102,21 +105,21 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 102 | 105 | log.WrapErr(err, "failed to deploy some attachments") | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 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) | |
| 107 | 110 | if err != nil { | |
| 108 | 111 | log.WrapErr(err, "failed to get exposed ports, using defaults") | |
| 109 | 112 | exposedPorts = []int{80, 8080, 3000} | |
| 110 | 113 | } | |
| 111 | 114 | ||
| 112 | 115 | // Load environment | |
| 113 | - | envVars, err := s.loadEnvironment(ctx, route.Domain, imageRef) | |
| 116 | + | envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef) | |
| 114 | 117 | if err != nil { | |
| 115 | 118 | return nil, err | |
| 116 | 119 | } | |
| 117 | 120 | ||
| 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) | |
| 120 | 123 | if err != nil { | |
| 121 | 124 | return nil, err | |
| 122 | 125 | } | |
| @@ -127,9 +130,9 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 127 | 130 | containerName = fmt.Sprintf("gordon-%s-new", route.Domain) | |
| 128 | 131 | } | |
| 129 | 132 | ||
| 130 | - | // Create container | |
| 133 | + | // Create container (use actual image ref from pull, but track original in labels) | |
| 131 | 134 | containerConfig := &domain.ContainerConfig{ | |
| 132 | - | Image: imageRef, | |
| 135 | + | Image: actualImageRef, | |
| 133 | 136 | Name: containerName, | |
| 134 | 137 | Ports: exposedPorts, | |
| 135 | 138 | Env: envVars, | |
| @@ -463,20 +466,34 @@ func (s *Service) buildImageRef(image string) string { | |||
| 463 | 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 | 473 | ctx = zerowrap.CtxWithField(ctx, "image", imageRef) | |
| 468 | 474 | log := zerowrap.FromCtx(ctx) | |
| 469 | 475 | ||
| 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 | |
| 471 | 488 | localImages, err := s.runtime.ListImages(ctx) | |
| 472 | 489 | if err != nil { | |
| 473 | 490 | log.WrapErr(err, "failed to list local images, will attempt pull") | |
| 474 | 491 | } else { | |
| 475 | - | normalizedRef := normalizeImageRef(imageRef) | |
| 492 | + | normalizedRef := normalizeImageRef(pullRef) | |
| 476 | 493 | for _, img := range localImages { | |
| 477 | 494 | if normalizeImageRef(img) == normalizedRef { | |
| 478 | 495 | log.Info().Msg("image found locally, skipping pull") | |
| 479 | - | return nil | |
| 496 | + | return pullRef, nil | |
| 480 | 497 | } | |
| 481 | 498 | } | |
| 482 | 499 | } | |
| @@ -484,18 +501,18 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) error { | |||
| 484 | 501 | // Pull image | |
| 485 | 502 | log.Info().Msg("pulling image from registry") | |
| 486 | 503 | ||
| 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") | |
| 490 | 507 | } | |
| 491 | 508 | } 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") | |
| 494 | 511 | } | |
| 495 | 512 | } | |
| 496 | 513 | ||
| 497 | 514 | log.Info().Msg("image pulled successfully") | |
| 498 | - | return nil | |
| 515 | + | return pullRef, nil | |
| 499 | 516 | } | |
| 500 | 517 | ||
| 501 | 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 | 713 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | |
| 697 | 714 | ||
| 698 | - | // Ensure image | |
| 715 | + | // Ensure image (get actual image ref, may be rewritten for internal pulls) | |
| 699 | 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 | 719 | return err | |
| 702 | 720 | } | |
| 703 | 721 | ||
| 704 | 722 | // Get image metadata | |
| 705 | - | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, imageRef) | |
| 723 | + | exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef) | |
| 706 | 724 | if err != nil { | |
| 707 | 725 | log.WrapErr(err, "failed to get exposed ports for attachment, using defaults") | |
| 708 | 726 | exposedPorts = []int{} | |
| 709 | 727 | } | |
| 710 | 728 | ||
| 711 | 729 | // Setup volumes (attachments need persistent data) | |
| 712 | - | volumes, err := s.setupVolumes(ctx, containerName, imageRef) | |
| 730 | + | volumes, err := s.setupVolumes(ctx, containerName, actualImageRef) | |
| 713 | 731 | if err != nil { | |
| 714 | 732 | log.WrapErr(err, "failed to setup volumes for attachment") | |
| 715 | 733 | volumes = make(map[string]string) | |
| 716 | 734 | } | |
| 717 | 735 | ||
| 718 | 736 | // Load environment (attachment-specific env file) | |
| 719 | - | envVars, err := s.loadEnvironment(ctx, containerName, imageRef) | |
| 737 | + | envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef) | |
| 720 | 738 | if err != nil { | |
| 721 | 739 | log.WrapErr(err, "failed to load environment for attachment") | |
| 722 | 740 | envVars = []string{} | |
| 723 | 741 | } | |
| 724 | 742 | ||
| 725 | - | // Create container with attachment labels | |
| 743 | + | // Create container with attachment labels (use actual ref, track original in labels) | |
| 726 | 744 | config := &domain.ContainerConfig{ | |
| 727 | - | Image: imageRef, | |
| 745 | + | Image: actualImageRef, | |
| 728 | 746 | Name: containerName, | |
| 729 | 747 | Hostname: serviceName, // Internal DNS: postgres, redis, etc. | |
| 730 | 748 | Ports: exposedPorts, | |
| @@ -946,3 +964,20 @@ func (s *Service) publishContainerDeployed(ctx context.Context, domainName, cont | |||
| 946 | 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 | + | } | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | type Config struct { | 20 | type Config struct { | ||
| 21 | RegistryAuthEnabled bool | 21 | RegistryAuthEnabled bool | ||
| 22 | RegistryDomain string | 22 | RegistryDomain string | ||
| 23 | + | RegistryPort int | |||
| 23 | RegistryUsername string | 24 | RegistryUsername string | ||
| 24 | RegistryPassword string | 25 | RegistryPassword string | ||
| 25 | VolumeAutoCreate bool | 26 | 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 available | 89 | // 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, err | 94 | 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 ports | 108 | + | // 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 environment | 115 | // 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, err | 118 | return nil, err | ||
| 116 | } | 119 | } | ||
| 117 | 120 | ||||
| 118 | - | // Setup volumes | 121 | + | // 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, err | 124 | 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 container | 133 | + | // 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 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 | |||
| 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 nil | 496 | + | 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 image | 501 | // 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 nil | 515 | + | 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 image | 715 | + | // 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 err | 719 | return err | ||
| 702 | } | 720 | } | ||
| 703 | 721 | ||||
| 704 | // Get image metadata | 722 | // 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 labels | 743 | + | // 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) { | |||
| 609 | 609 | assert.Contains(t, result, "BAR=docker") | |
| 610 | 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 | + | } | |
| @@ -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 | + | } | |||