Merge pull request #52 from bnema/fix/security-audit-and-localhost-registry
fix(security): UTC timestamps and localhost registry image rewriting
5 files · +200 -12
M internal/adapters/in/http/auth/handler.go +3 -3
| @@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||
| 147 | 147 | response := PasswordResponse{ | |
| 148 | 148 | Token: token, | |
| 149 | 149 | ExpiresIn: int(expiry.Seconds()), | |
| 150 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 150 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | 153 | w.Header().Set("Content-Type", "application/json") | |
| @@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||
| 244 | 244 | response := dto.TokenResponse{ | |
| 245 | 245 | Token: accessToken, | |
| 246 | 246 | ExpiresIn: 300, // 5 minutes in seconds | |
| 247 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 247 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | 250 | w.Header().Set("Content-Type", "application/json") | |
| @@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) | |||
| 266 | 266 | response := dto.TokenResponse{ | |
| 267 | 267 | Token: "", // Empty token indicates limited access | |
| 268 | 268 | ExpiresIn: 60, // 1 minute | |
| 269 | - | IssuedAt: time.Now().Format(time.RFC3339), | |
| 269 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | 272 | w.Header().Set("Content-Type", "application/json") | |
| @@ -147,7 +147,7 @@ func (h *Handler) handlePassword(w http.ResponseWriter, r *http.Request) { | |||||
| 147 | response := PasswordResponse{ | 147 | response := PasswordResponse{ | ||
| 148 | Token: token, | 148 | Token: token, | ||
| 149 | ExpiresIn: int(expiry.Seconds()), | 149 | ExpiresIn: int(expiry.Seconds()), | ||
| 150 | - | IssuedAt: time.Now().Format(time.RFC3339), | 150 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | w.Header().Set("Content-Type", "application/json") | 153 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -244,7 +244,7 @@ func (h *Handler) handleToken(w http.ResponseWriter, r *http.Request) { | |||||
| 244 | response := dto.TokenResponse{ | 244 | response := dto.TokenResponse{ | ||
| 245 | Token: accessToken, | 245 | Token: accessToken, | ||
| 246 | ExpiresIn: 300, // 5 minutes in seconds | 246 | ExpiresIn: 300, // 5 minutes in seconds | ||
| 247 | - | IssuedAt: time.Now().Format(time.RFC3339), | 247 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 248 | } | 248 | } | ||
| 249 | 249 | ||||
| 250 | w.Header().Set("Content-Type", "application/json") | 250 | w.Header().Set("Content-Type", "application/json") | ||
| @@ -266,7 +266,7 @@ func (h *Handler) sendAnonymousToken(w http.ResponseWriter, log zerowrap.Logger) | |||||
| 266 | response := dto.TokenResponse{ | 266 | response := dto.TokenResponse{ | ||
| 267 | Token: "", // Empty token indicates limited access | 267 | Token: "", // Empty token indicates limited access | ||
| 268 | ExpiresIn: 60, // 1 minute | 268 | ExpiresIn: 60, // 1 minute | ||
| 269 | - | IssuedAt: time.Now().Format(time.RFC3339), | 269 | + | IssuedAt: time.Now().UTC().Format(time.RFC3339), |
| 270 | } | 270 | } | ||
| 271 | 271 | ||||
| 272 | w.Header().Set("Content-Type", "application/json") | 272 | w.Header().Set("Content-Type", "application/json") | ||
M internal/domain/auth.go +1 -1
| @@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool { | |||
| 43 | 43 | if t.ExpiresAt.IsZero() { | |
| 44 | 44 | return false // Never expires | |
| 45 | 45 | } | |
| 46 | - | return time.Now().After(t.ExpiresAt) | |
| 46 | + | return time.Now().UTC().After(t.ExpiresAt) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | // SecretsBackend represents the type of secrets storage backend. | |
| @@ -43,7 +43,7 @@ func (t *Token) IsExpired() bool { | |||||
| 43 | if t.ExpiresAt.IsZero() { | 43 | if t.ExpiresAt.IsZero() { | ||
| 44 | return false // Never expires | 44 | return false // Never expires | ||
| 45 | } | 45 | } | ||
| 46 | - | return time.Now().After(t.ExpiresAt) | 46 | + | return time.Now().UTC().After(t.ExpiresAt) |
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | // SecretsBackend represents the type of secrets storage backend. | 49 | // SecretsBackend represents the type of secrets storage backend. | ||
M internal/usecase/auth/service.go +4 -4
| @@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||
| 231 | 231 | return nil | |
| 232 | 232 | } | |
| 233 | 233 | ||
| 234 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | |
| 234 | + | if time.Now().UTC().Unix() > tokenClaims.ExpiresAt { | |
| 235 | 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |
| 236 | 236 | return domain.ErrExpiredToken | |
| 237 | 237 | } | |
| @@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||
| 256 | 256 | ||
| 257 | 257 | // Security: ensure token was recently issued to prevent attackers with | |
| 258 | 258 | // stolen secrets from creating arbitrary short-lived tokens | |
| 259 | - | now := time.Now().Unix() | |
| 259 | + | now := time.Now().UTC().Unix() | |
| 260 | 260 | tokenAge := now - claims.IssuedAt | |
| 261 | 261 | if tokenAge > maxAccessTokenLifetimeSecs { | |
| 262 | 262 | return false // Old tokens require store validation | |
| @@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||
| 275 | 275 | log := zerowrap.FromCtx(ctx) | |
| 276 | 276 | ||
| 277 | 277 | tokenID := uuid.New().String() | |
| 278 | - | now := time.Now() | |
| 278 | + | now := time.Now().UTC() | |
| 279 | 279 | ||
| 280 | 280 | // Build token | |
| 281 | 281 | token := &domain.Token{ | |
| @@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | 347 | tokenID := uuid.New().String() | |
| 348 | - | now := time.Now() | |
| 348 | + | now := time.Now().UTC() | |
| 349 | 349 | ||
| 350 | 350 | claims := jwt.MapClaims{ | |
| 351 | 351 | "jti": tokenID, | |
| @@ -231,7 +231,7 @@ func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) | |||||
| 231 | return nil | 231 | return nil | ||
| 232 | } | 232 | } | ||
| 233 | 233 | ||||
| 234 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | 234 | + | if time.Now().UTC().Unix() > tokenClaims.ExpiresAt { |
| 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | 235 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | ||
| 236 | return domain.ErrExpiredToken | 236 | return domain.ErrExpiredToken | ||
| 237 | } | 237 | } | ||
| @@ -256,7 +256,7 @@ func (s *Service) isEphemeralAccessToken(claims *domain.TokenClaims) bool { | |||||
| 256 | 256 | ||||
| 257 | // Security: ensure token was recently issued to prevent attackers with | 257 | // Security: ensure token was recently issued to prevent attackers with | ||
| 258 | // stolen secrets from creating arbitrary short-lived tokens | 258 | // stolen secrets from creating arbitrary short-lived tokens | ||
| 259 | - | now := time.Now().Unix() | 259 | + | now := time.Now().UTC().Unix() |
| 260 | tokenAge := now - claims.IssuedAt | 260 | tokenAge := now - claims.IssuedAt | ||
| 261 | if tokenAge > maxAccessTokenLifetimeSecs { | 261 | if tokenAge > maxAccessTokenLifetimeSecs { | ||
| 262 | return false // Old tokens require store validation | 262 | return false // Old tokens require store validation | ||
| @@ -275,7 +275,7 @@ func (s *Service) GenerateToken(ctx context.Context, subject string, scopes []st | |||||
| 275 | log := zerowrap.FromCtx(ctx) | 275 | log := zerowrap.FromCtx(ctx) | ||
| 276 | 276 | ||||
| 277 | tokenID := uuid.New().String() | 277 | tokenID := uuid.New().String() | ||
| 278 | - | now := time.Now() | 278 | + | now := time.Now().UTC() |
| 279 | 279 | ||||
| 280 | // Build token | 280 | // Build token | ||
| 281 | token := &domain.Token{ | 281 | token := &domain.Token{ | ||
| @@ -345,7 +345,7 @@ func (s *Service) GenerateAccessToken(ctx context.Context, subject string, scope | |||||
| 345 | } | 345 | } | ||
| 346 | 346 | ||||
| 347 | tokenID := uuid.New().String() | 347 | tokenID := uuid.New().String() | ||
| 348 | - | now := time.Now() | 348 | + | now := time.Now().UTC() |
| 349 | 349 | ||||
| 350 | claims := jwt.MapClaims{ | 350 | claims := jwt.MapClaims{ | ||
| 351 | "jti": tokenID, | 351 | "jti": tokenID, | ||
M internal/usecase/container/service.go +70 -4
| @@ -636,14 +636,80 @@ func (s *Service) buildImageRef(image string) string { | |||
| 636 | 636 | return image | |
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | - | repoPart := strings.Split(image, ":")[0] | |
| 640 | - | if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") { | |
| 639 | + | // Normalize registry domain by trimming any trailing slash | |
| 640 | + | reg := strings.TrimSuffix(s.config.RegistryDomain, "/") | |
| 641 | + | ||
| 642 | + | // Check if image already has the registry domain prefix | |
| 643 | + | prefix := reg + "/" | |
| 644 | + | if strings.HasPrefix(image, prefix) { | |
| 641 | 645 | return image | |
| 642 | 646 | } | |
| 643 | - | if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") { | |
| 647 | + | ||
| 648 | + | // Check if image already has an explicit registry (host:port/ or host.domain/). | |
| 649 | + | // We need to detect patterns like: | |
| 650 | + | // - "docker.io/library/nginx:latest" (has dot and slash) | |
| 651 | + | // - "localhost:5001/myapp:latest" (has host:port/ pattern) | |
| 652 | + | // - "myregistry:5000/app:v1" (has host:port/ pattern) | |
| 653 | + | // - "[fd00::1]:5000/app:v1" (IPv6 with port) | |
| 654 | + | if hasExplicitRegistry(image) { | |
| 644 | 655 | return image | |
| 645 | 656 | } | |
| 646 | - | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | |
| 657 | + | ||
| 658 | + | return fmt.Sprintf("%s/%s", reg, image) | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // hasExplicitRegistry checks if an image reference already includes an explicit registry. | |
| 662 | + | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", | |
| 663 | + | // "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image". | |
| 664 | + | func hasExplicitRegistry(image string) bool { | |
| 665 | + | // Find the first slash which separates registry from image name | |
| 666 | + | slashIdx := strings.Index(image, "/") | |
| 667 | + | if slashIdx == -1 { | |
| 668 | + | return false // No slash means no registry prefix (e.g., "myapp:latest") | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // Extract the part before the first slash (potential registry) | |
| 672 | + | registryPart := image[:slashIdx] | |
| 673 | + | ||
| 674 | + | // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000") | |
| 675 | + | if strings.HasPrefix(registryPart, "[") { | |
| 676 | + | // Look for closing bracket | |
| 677 | + | if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 { | |
| 678 | + | // Valid bracketed IPv6: either ends at ] or has ]:port | |
| 679 | + | return true | |
| 680 | + | } | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | // Check for localhost (with or without port) | |
| 684 | + | if registryPart == "localhost" { | |
| 685 | + | return true | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | // Check for host:port pattern (e.g., "localhost:5000", "registry:8080") | |
| 689 | + | if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 { | |
| 690 | + | port := registryPart[colonIdx+1:] | |
| 691 | + | // If everything after colon is digits, it's a port | |
| 692 | + | if len(port) > 0 && isNumeric(port) { | |
| 693 | + | return true | |
| 694 | + | } | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io") | |
| 698 | + | if strings.Contains(registryPart, ".") { | |
| 699 | + | return true | |
| 700 | + | } | |
| 701 | + | ||
| 702 | + | return false | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | // isNumeric checks if a string contains only digits. | |
| 706 | + | func isNumeric(s string) bool { | |
| 707 | + | for _, c := range s { | |
| 708 | + | if c < '0' || c > '9' { | |
| 709 | + | return false | |
| 710 | + | } | |
| 711 | + | } | |
| 712 | + | return true | |
| 647 | 713 | } | |
| 648 | 714 | ||
| 649 | 715 | func normalizePullPolicy(policy string) string { | |
| @@ -636,14 +636,80 @@ func (s *Service) buildImageRef(image string) string { | |||||
| 636 | return image | 636 | return image | ||
| 637 | } | 637 | } | ||
| 638 | 638 | ||||
| 639 | - | repoPart := strings.Split(image, ":")[0] | 639 | + | // Normalize registry domain by trimming any trailing slash |
| 640 | - | if strings.HasPrefix(repoPart, s.config.RegistryDomain+"/") { | 640 | + | reg := strings.TrimSuffix(s.config.RegistryDomain, "/") |
| 641 | + | ||||
| 642 | + | // Check if image already has the registry domain prefix | |||
| 643 | + | prefix := reg + "/" | |||
| 644 | + | if strings.HasPrefix(image, prefix) { | |||
| 641 | return image | 645 | return image | ||
| 642 | } | 646 | } | ||
| 643 | - | if strings.Contains(repoPart, ".") && strings.Contains(repoPart, "/") { | 647 | + | |
| 648 | + | // Check if image already has an explicit registry (host:port/ or host.domain/). | |||
| 649 | + | // We need to detect patterns like: | |||
| 650 | + | // - "docker.io/library/nginx:latest" (has dot and slash) | |||
| 651 | + | // - "localhost:5001/myapp:latest" (has host:port/ pattern) | |||
| 652 | + | // - "myregistry:5000/app:v1" (has host:port/ pattern) | |||
| 653 | + | // - "[fd00::1]:5000/app:v1" (IPv6 with port) | |||
| 654 | + | if hasExplicitRegistry(image) { | |||
| 644 | return image | 655 | return image | ||
| 645 | } | 656 | } | ||
| 646 | - | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | 657 | + | |
| 658 | + | return fmt.Sprintf("%s/%s", reg, image) | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | // hasExplicitRegistry checks if an image reference already includes an explicit registry. | |||
| 662 | + | // Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image", | |||
| 663 | + | // "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image". | |||
| 664 | + | func hasExplicitRegistry(image string) bool { | |||
| 665 | + | // Find the first slash which separates registry from image name | |||
| 666 | + | slashIdx := strings.Index(image, "/") | |||
| 667 | + | if slashIdx == -1 { | |||
| 668 | + | return false // No slash means no registry prefix (e.g., "myapp:latest") | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // Extract the part before the first slash (potential registry) | |||
| 672 | + | registryPart := image[:slashIdx] | |||
| 673 | + | ||||
| 674 | + | // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000") | |||
| 675 | + | if strings.HasPrefix(registryPart, "[") { | |||
| 676 | + | // Look for closing bracket | |||
| 677 | + | if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 { | |||
| 678 | + | // Valid bracketed IPv6: either ends at ] or has ]:port | |||
| 679 | + | return true | |||
| 680 | + | } | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | // Check for localhost (with or without port) | |||
| 684 | + | if registryPart == "localhost" { | |||
| 685 | + | return true | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | // Check for host:port pattern (e.g., "localhost:5000", "registry:8080") | |||
| 689 | + | if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 { | |||
| 690 | + | port := registryPart[colonIdx+1:] | |||
| 691 | + | // If everything after colon is digits, it's a port | |||
| 692 | + | if len(port) > 0 && isNumeric(port) { | |||
| 693 | + | return true | |||
| 694 | + | } | |||
| 695 | + | } | |||
| 696 | + | ||||
| 697 | + | // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io") | |||
| 698 | + | if strings.Contains(registryPart, ".") { | |||
| 699 | + | return true | |||
| 700 | + | } | |||
| 701 | + | ||||
| 702 | + | return false | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | // isNumeric checks if a string contains only digits. | |||
| 706 | + | func isNumeric(s string) bool { | |||
| 707 | + | for _, c := range s { | |||
| 708 | + | if c < '0' || c > '9' { | |||
| 709 | + | return false | |||
| 710 | + | } | |||
| 711 | + | } | |||
| 712 | + | return true | |||
| 647 | } | 713 | } | ||
| 648 | 714 | ||||
| 649 | func normalizePullPolicy(policy string) string { | 715 | func normalizePullPolicy(policy string) string { | ||
M internal/usecase/container/service_test.go +122 -0
| @@ -743,6 +743,128 @@ func TestMergeEnvironmentVariables(t *testing.T) { | |||
| 743 | 743 | assert.Contains(t, result, "BAZ=user") | |
| 744 | 744 | } | |
| 745 | 745 | ||
| 746 | + | func TestBuildImageRef(t *testing.T) { | |
| 747 | + | tests := []struct { | |
| 748 | + | name string | |
| 749 | + | image string | |
| 750 | + | registryAuthEnabled bool | |
| 751 | + | registryDomain string | |
| 752 | + | wantRef string | |
| 753 | + | }{ | |
| 754 | + | { | |
| 755 | + | name: "adds registry domain prefix", | |
| 756 | + | image: "myapp:latest", | |
| 757 | + | registryAuthEnabled: true, | |
| 758 | + | registryDomain: "registry.example.com", | |
| 759 | + | wantRef: "registry.example.com/myapp:latest", | |
| 760 | + | }, | |
| 761 | + | { | |
| 762 | + | name: "keeps existing registry domain prefix", | |
| 763 | + | image: "registry.example.com/myapp:latest", | |
| 764 | + | registryAuthEnabled: true, | |
| 765 | + | registryDomain: "registry.example.com", | |
| 766 | + | wantRef: "registry.example.com/myapp:latest", | |
| 767 | + | }, | |
| 768 | + | { | |
| 769 | + | name: "skips external registry images", | |
| 770 | + | image: "docker.io/library/nginx:latest", | |
| 771 | + | registryAuthEnabled: true, | |
| 772 | + | registryDomain: "registry.example.com", | |
| 773 | + | wantRef: "docker.io/library/nginx:latest", | |
| 774 | + | }, | |
| 775 | + | { | |
| 776 | + | name: "returns original when auth disabled", | |
| 777 | + | image: "myapp:latest", | |
| 778 | + | registryAuthEnabled: false, | |
| 779 | + | registryDomain: "registry.example.com", | |
| 780 | + | wantRef: "myapp:latest", | |
| 781 | + | }, | |
| 782 | + | { | |
| 783 | + | name: "localhost domain adds prefix", | |
| 784 | + | image: "myapp:latest", | |
| 785 | + | registryAuthEnabled: true, | |
| 786 | + | registryDomain: "localhost:5000", | |
| 787 | + | wantRef: "localhost:5000/myapp:latest", | |
| 788 | + | }, | |
| 789 | + | { | |
| 790 | + | name: "localhost domain keeps existing prefix", | |
| 791 | + | image: "localhost:5000/myapp:latest", | |
| 792 | + | registryAuthEnabled: true, | |
| 793 | + | registryDomain: "localhost:5000", | |
| 794 | + | wantRef: "localhost:5000/myapp:latest", | |
| 795 | + | }, | |
| 796 | + | { | |
| 797 | + | name: "explicit host:port different than RegistryDomain", | |
| 798 | + | image: "localhost:5001/myapp:latest", | |
| 799 | + | registryAuthEnabled: true, | |
| 800 | + | registryDomain: "localhost:5000", | |
| 801 | + | wantRef: "localhost:5001/myapp:latest", | |
| 802 | + | }, | |
| 803 | + | { | |
| 804 | + | name: "ghcr.io external registry", | |
| 805 | + | image: "ghcr.io/owner/repo:latest", | |
| 806 | + | registryAuthEnabled: true, | |
| 807 | + | registryDomain: "localhost:5000", | |
| 808 | + | wantRef: "ghcr.io/owner/repo:latest", | |
| 809 | + | }, | |
| 810 | + | { | |
| 811 | + | name: "gcr.io external registry", | |
| 812 | + | image: "gcr.io/project/image:v1", | |
| 813 | + | registryAuthEnabled: true, | |
| 814 | + | registryDomain: "registry.example.com", | |
| 815 | + | wantRef: "gcr.io/project/image:v1", | |
| 816 | + | }, | |
| 817 | + | { | |
| 818 | + | name: "quay.io external registry", | |
| 819 | + | image: "quay.io/org/app:tag", | |
| 820 | + | registryAuthEnabled: true, | |
| 821 | + | registryDomain: "localhost:5000", | |
| 822 | + | wantRef: "quay.io/org/app:tag", | |
| 823 | + | }, | |
| 824 | + | { | |
| 825 | + | name: "localhost without port keeps existing prefix", | |
| 826 | + | image: "localhost/myapp:latest", | |
| 827 | + | registryAuthEnabled: true, | |
| 828 | + | registryDomain: "localhost:5000", | |
| 829 | + | wantRef: "localhost/myapp:latest", | |
| 830 | + | }, | |
| 831 | + | { | |
| 832 | + | name: "registry domain with trailing slash", | |
| 833 | + | image: "myapp:latest", | |
| 834 | + | registryAuthEnabled: true, | |
| 835 | + | registryDomain: "registry.example.com/", | |
| 836 | + | wantRef: "registry.example.com/myapp:latest", | |
| 837 | + | }, | |
| 838 | + | { | |
| 839 | + | name: "ipv6 registry without port", | |
| 840 | + | image: "[fd00::1]/myapp:latest", | |
| 841 | + | registryAuthEnabled: true, | |
| 842 | + | registryDomain: "localhost:5000", | |
| 843 | + | wantRef: "[fd00::1]/myapp:latest", | |
| 844 | + | }, | |
| 845 | + | { | |
| 846 | + | name: "ipv6 registry with port", | |
| 847 | + | image: "[fd00::1]:5000/myapp:latest", | |
| 848 | + | registryAuthEnabled: true, | |
| 849 | + | registryDomain: "localhost:5000", | |
| 850 | + | wantRef: "[fd00::1]:5000/myapp:latest", | |
| 851 | + | }, | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | for _, tt := range tests { | |
| 855 | + | t.Run(tt.name, func(t *testing.T) { | |
| 856 | + | svc := &Service{ | |
| 857 | + | config: Config{ | |
| 858 | + | RegistryAuthEnabled: tt.registryAuthEnabled, | |
| 859 | + | RegistryDomain: tt.registryDomain, | |
| 860 | + | }, | |
| 861 | + | } | |
| 862 | + | gotRef := svc.buildImageRef(tt.image) | |
| 863 | + | assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference") | |
| 864 | + | }) | |
| 865 | + | } | |
| 866 | + | } | |
| 867 | + | ||
| 746 | 868 | func TestRewriteToRegistryDomain(t *testing.T) { | |
| 747 | 869 | tests := []struct { | |
| 748 | 870 | name string | |
| @@ -743,6 +743,128 @@ func TestMergeEnvironmentVariables(t *testing.T) { | |||||
| 743 | assert.Contains(t, result, "BAZ=user") | 743 | assert.Contains(t, result, "BAZ=user") | ||
| 744 | } | 744 | } | ||
| 745 | 745 | ||||
| 746 | + | func TestBuildImageRef(t *testing.T) { | |||
| 747 | + | tests := []struct { | |||
| 748 | + | name string | |||
| 749 | + | image string | |||
| 750 | + | registryAuthEnabled bool | |||
| 751 | + | registryDomain string | |||
| 752 | + | wantRef string | |||
| 753 | + | }{ | |||
| 754 | + | { | |||
| 755 | + | name: "adds registry domain prefix", | |||
| 756 | + | image: "myapp:latest", | |||
| 757 | + | registryAuthEnabled: true, | |||
| 758 | + | registryDomain: "registry.example.com", | |||
| 759 | + | wantRef: "registry.example.com/myapp:latest", | |||
| 760 | + | }, | |||
| 761 | + | { | |||
| 762 | + | name: "keeps existing registry domain prefix", | |||
| 763 | + | image: "registry.example.com/myapp:latest", | |||
| 764 | + | registryAuthEnabled: true, | |||
| 765 | + | registryDomain: "registry.example.com", | |||
| 766 | + | wantRef: "registry.example.com/myapp:latest", | |||
| 767 | + | }, | |||
| 768 | + | { | |||
| 769 | + | name: "skips external registry images", | |||
| 770 | + | image: "docker.io/library/nginx:latest", | |||
| 771 | + | registryAuthEnabled: true, | |||
| 772 | + | registryDomain: "registry.example.com", | |||
| 773 | + | wantRef: "docker.io/library/nginx:latest", | |||
| 774 | + | }, | |||
| 775 | + | { | |||
| 776 | + | name: "returns original when auth disabled", | |||
| 777 | + | image: "myapp:latest", | |||
| 778 | + | registryAuthEnabled: false, | |||
| 779 | + | registryDomain: "registry.example.com", | |||
| 780 | + | wantRef: "myapp:latest", | |||
| 781 | + | }, | |||
| 782 | + | { | |||
| 783 | + | name: "localhost domain adds prefix", | |||
| 784 | + | image: "myapp:latest", | |||
| 785 | + | registryAuthEnabled: true, | |||
| 786 | + | registryDomain: "localhost:5000", | |||
| 787 | + | wantRef: "localhost:5000/myapp:latest", | |||
| 788 | + | }, | |||
| 789 | + | { | |||
| 790 | + | name: "localhost domain keeps existing prefix", | |||
| 791 | + | image: "localhost:5000/myapp:latest", | |||
| 792 | + | registryAuthEnabled: true, | |||
| 793 | + | registryDomain: "localhost:5000", | |||
| 794 | + | wantRef: "localhost:5000/myapp:latest", | |||
| 795 | + | }, | |||
| 796 | + | { | |||
| 797 | + | name: "explicit host:port different than RegistryDomain", | |||
| 798 | + | image: "localhost:5001/myapp:latest", | |||
| 799 | + | registryAuthEnabled: true, | |||
| 800 | + | registryDomain: "localhost:5000", | |||
| 801 | + | wantRef: "localhost:5001/myapp:latest", | |||
| 802 | + | }, | |||
| 803 | + | { | |||
| 804 | + | name: "ghcr.io external registry", | |||
| 805 | + | image: "ghcr.io/owner/repo:latest", | |||
| 806 | + | registryAuthEnabled: true, | |||
| 807 | + | registryDomain: "localhost:5000", | |||
| 808 | + | wantRef: "ghcr.io/owner/repo:latest", | |||
| 809 | + | }, | |||
| 810 | + | { | |||
| 811 | + | name: "gcr.io external registry", | |||
| 812 | + | image: "gcr.io/project/image:v1", | |||
| 813 | + | registryAuthEnabled: true, | |||
| 814 | + | registryDomain: "registry.example.com", | |||
| 815 | + | wantRef: "gcr.io/project/image:v1", | |||
| 816 | + | }, | |||
| 817 | + | { | |||
| 818 | + | name: "quay.io external registry", | |||
| 819 | + | image: "quay.io/org/app:tag", | |||
| 820 | + | registryAuthEnabled: true, | |||
| 821 | + | registryDomain: "localhost:5000", | |||
| 822 | + | wantRef: "quay.io/org/app:tag", | |||
| 823 | + | }, | |||
| 824 | + | { | |||
| 825 | + | name: "localhost without port keeps existing prefix", | |||
| 826 | + | image: "localhost/myapp:latest", | |||
| 827 | + | registryAuthEnabled: true, | |||
| 828 | + | registryDomain: "localhost:5000", | |||
| 829 | + | wantRef: "localhost/myapp:latest", | |||
| 830 | + | }, | |||
| 831 | + | { | |||
| 832 | + | name: "registry domain with trailing slash", | |||
| 833 | + | image: "myapp:latest", | |||
| 834 | + | registryAuthEnabled: true, | |||
| 835 | + | registryDomain: "registry.example.com/", | |||
| 836 | + | wantRef: "registry.example.com/myapp:latest", | |||
| 837 | + | }, | |||
| 838 | + | { | |||
| 839 | + | name: "ipv6 registry without port", | |||
| 840 | + | image: "[fd00::1]/myapp:latest", | |||
| 841 | + | registryAuthEnabled: true, | |||
| 842 | + | registryDomain: "localhost:5000", | |||
| 843 | + | wantRef: "[fd00::1]/myapp:latest", | |||
| 844 | + | }, | |||
| 845 | + | { | |||
| 846 | + | name: "ipv6 registry with port", | |||
| 847 | + | image: "[fd00::1]:5000/myapp:latest", | |||
| 848 | + | registryAuthEnabled: true, | |||
| 849 | + | registryDomain: "localhost:5000", | |||
| 850 | + | wantRef: "[fd00::1]:5000/myapp:latest", | |||
| 851 | + | }, | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | for _, tt := range tests { | |||
| 855 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 856 | + | svc := &Service{ | |||
| 857 | + | config: Config{ | |||
| 858 | + | RegistryAuthEnabled: tt.registryAuthEnabled, | |||
| 859 | + | RegistryDomain: tt.registryDomain, | |||
| 860 | + | }, | |||
| 861 | + | } | |||
| 862 | + | gotRef := svc.buildImageRef(tt.image) | |||
| 863 | + | assert.Equal(t, tt.wantRef, gotRef, "unexpected image reference") | |||
| 864 | + | }) | |||
| 865 | + | } | |||
| 866 | + | } | |||
| 867 | + | ||||
| 746 | func TestRewriteToRegistryDomain(t *testing.T) { | 868 | func TestRewriteToRegistryDomain(t *testing.T) { | ||
| 747 | tests := []struct { | 869 | tests := []struct { | ||
| 748 | name string | 870 | name string | ||