Refresh auto-route domains and dedupe attachments
- commit
- 2b371d2c265c680b6928fc162b75fd70a6ed3540
- author
- brice · · 2026-05-10 13:59 UTC
- parent
- 637b278
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +175 -12
M internal/usecase/config/service.go +18 -4
| @@ -886,9 +886,15 @@ func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain | |||
| 886 | 886 | continue | |
| 887 | 887 | } | |
| 888 | 888 | ||
| 889 | - | canonicalImages := make([]string, len(images)) | |
| 890 | - | for i, image := range images { | |
| 891 | - | canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | |
| 889 | + | canonicalImages := make([]string, 0, len(images)) | |
| 890 | + | seen := make(map[string]struct{}, len(images)) | |
| 891 | + | for _, image := range images { | |
| 892 | + | canonicalImage := domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | |
| 893 | + | if _, ok := seen[canonicalImage]; ok { | |
| 894 | + | continue | |
| 895 | + | } | |
| 896 | + | seen[canonicalImage] = struct{}{} | |
| 897 | + | canonicalImages = append(canonicalImages, canonicalImage) | |
| 892 | 898 | } | |
| 893 | 899 | result[target] = canonicalImages | |
| 894 | 900 | } | |
| @@ -1143,6 +1149,13 @@ func (s *Service) GetRegistryDomain() string { | |||
| 1143 | 1149 | return s.config.RegistryDomain | |
| 1144 | 1150 | } | |
| 1145 | 1151 | ||
| 1152 | + | // GetLegacyRegistryDomains returns the configured legacy registry domains. | |
| 1153 | + | func (s *Service) GetLegacyRegistryDomains() []string { | |
| 1154 | + | s.mu.RLock() | |
| 1155 | + | defer s.mu.RUnlock() | |
| 1156 | + | return append([]string{}, s.config.LegacyRegistryDomains...) | |
| 1157 | + | } | |
| 1158 | + | ||
| 1146 | 1159 | // GetDataDir returns the configured data directory. | |
| 1147 | 1160 | func (s *Service) GetDataDir() string { | |
| 1148 | 1161 | s.mu.RLock() | |
| @@ -1271,8 +1284,9 @@ func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string | |||
| 1271 | 1284 | ||
| 1272 | 1285 | // Check if already exists | |
| 1273 | 1286 | existing := s.config.Attachments[domainOrGroup] | |
| 1287 | + | canonicalImage := domain.CanonicalizeGordonImageRef(image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) | |
| 1274 | 1288 | for _, img := range existing { | |
| 1275 | - | if img == image { | |
| 1289 | + | if img == image || domain.CanonicalizeGordonImageRef(img, s.config.RegistryDomain, s.config.LegacyRegistryDomains) == canonicalImage { | |
| 1276 | 1290 | s.mu.Unlock() | |
| 1277 | 1291 | return domain.ErrAttachmentExists | |
| 1278 | 1292 | } | |
| @@ -886,9 +886,15 @@ func canonicalAttachmentsForSave(attachments map[string][]string, registryDomain | |||||
| 886 | continue | 886 | continue | ||
| 887 | } | 887 | } | ||
| 888 | 888 | ||||
| 889 | - | canonicalImages := make([]string, len(images)) | 889 | + | canonicalImages := make([]string, 0, len(images)) |
| 890 | - | for i, image := range images { | 890 | + | seen := make(map[string]struct{}, len(images)) |
| 891 | - | canonicalImages[i] = domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | 891 | + | for _, image := range images { |
| 892 | + | canonicalImage := domain.CanonicalizeGordonImageRef(image, registryDomain, legacyRegistryDomains) | |||
| 893 | + | if _, ok := seen[canonicalImage]; ok { | |||
| 894 | + | continue | |||
| 895 | + | } | |||
| 896 | + | seen[canonicalImage] = struct{}{} | |||
| 897 | + | canonicalImages = append(canonicalImages, canonicalImage) | |||
| 892 | } | 898 | } | ||
| 893 | result[target] = canonicalImages | 899 | result[target] = canonicalImages | ||
| 894 | } | 900 | } | ||
| @@ -1143,6 +1149,13 @@ func (s *Service) GetRegistryDomain() string { | |||||
| 1143 | return s.config.RegistryDomain | 1149 | return s.config.RegistryDomain | ||
| 1144 | } | 1150 | } | ||
| 1145 | 1151 | ||||
| 1152 | + | // GetLegacyRegistryDomains returns the configured legacy registry domains. | |||
| 1153 | + | func (s *Service) GetLegacyRegistryDomains() []string { | |||
| 1154 | + | s.mu.RLock() | |||
| 1155 | + | defer s.mu.RUnlock() | |||
| 1156 | + | return append([]string{}, s.config.LegacyRegistryDomains...) | |||
| 1157 | + | } | |||
| 1158 | + | ||||
| 1146 | // GetDataDir returns the configured data directory. | 1159 | // GetDataDir returns the configured data directory. | ||
| 1147 | func (s *Service) GetDataDir() string { | 1160 | func (s *Service) GetDataDir() string { | ||
| 1148 | s.mu.RLock() | 1161 | s.mu.RLock() | ||
| @@ -1271,8 +1284,9 @@ func (s *Service) AddAttachment(ctx context.Context, domainOrGroup, image string | |||||
| 1271 | 1284 | ||||
| 1272 | // Check if already exists | 1285 | // Check if already exists | ||
| 1273 | existing := s.config.Attachments[domainOrGroup] | 1286 | existing := s.config.Attachments[domainOrGroup] | ||
| 1287 | + | canonicalImage := domain.CanonicalizeGordonImageRef(image, s.config.RegistryDomain, s.config.LegacyRegistryDomains) | |||
| 1274 | for _, img := range existing { | 1288 | for _, img := range existing { | ||
| 1275 | - | if img == image { | 1289 | + | if img == image || domain.CanonicalizeGordonImageRef(img, s.config.RegistryDomain, s.config.LegacyRegistryDomains) == canonicalImage { |
| 1276 | s.mu.Unlock() | 1290 | s.mu.Unlock() | ||
| 1277 | return domain.ErrAttachmentExists | 1291 | return domain.ErrAttachmentExists | ||
| 1278 | } | 1292 | } | ||
M internal/usecase/config/service_test.go +86 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "path/filepath" | |
| 8 | + | "strings" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -339,6 +340,31 @@ legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com | |||
| 339 | 340 | assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) | |
| 340 | 341 | } | |
| 341 | 342 | ||
| 343 | + | func TestCanonicalAttachmentsForSave(t *testing.T) { | |
| 344 | + | attachments := map[string][]string{ | |
| 345 | + | "app.example.com": { | |
| 346 | + | "old-registry.example.com/postgres:18", | |
| 347 | + | "registry.example.com/postgres:18", | |
| 348 | + | "rabbitmq:3", | |
| 349 | + | "docker.io/library/nginx:latest", | |
| 350 | + | }, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | saved := canonicalAttachmentsForSave(attachments, "registry.example.com", []string{"old-registry.example.com"}) | |
| 354 | + | ||
| 355 | + | assert.Equal(t, []string{ | |
| 356 | + | "registry.example.com/postgres:18", | |
| 357 | + | "rabbitmq:3", | |
| 358 | + | "docker.io/library/nginx:latest", | |
| 359 | + | }, saved["app.example.com"]) | |
| 360 | + | assert.Equal(t, []string{ | |
| 361 | + | "old-registry.example.com/postgres:18", | |
| 362 | + | "registry.example.com/postgres:18", | |
| 363 | + | "rabbitmq:3", | |
| 364 | + | "docker.io/library/nginx:latest", | |
| 365 | + | }, attachments["app.example.com"]) | |
| 366 | + | } | |
| 367 | + | ||
| 342 | 368 | func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |
| 343 | 369 | tmpDir := t.TempDir() | |
| 344 | 370 | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| @@ -349,6 +375,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||
| 349 | 375 | [attachments] | |
| 350 | 376 | "app.example.com" = [ | |
| 351 | 377 | "old-registry.example.com/postgres:18", | |
| 378 | + | "registry.example.com/postgres:18", | |
| 352 | 379 | "registry.example.com/redis:7", | |
| 353 | 380 | "rabbitmq:3", | |
| 354 | 381 | "docker.io/library/nginx:latest", | |
| @@ -368,6 +395,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||
| 368 | 395 | require.NoError(t, svc.Load(ctx)) | |
| 369 | 396 | require.Equal(t, []string{ | |
| 370 | 397 | "old-registry.example.com/postgres:18", | |
| 398 | + | "registry.example.com/postgres:18", | |
| 371 | 399 | "registry.example.com/redis:7", | |
| 372 | 400 | "rabbitmq:3", | |
| 373 | 401 | "docker.io/library/nginx:latest", | |
| @@ -376,6 +404,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||
| 376 | 404 | require.NoError(t, svc.Save(ctx)) | |
| 377 | 405 | require.Equal(t, []string{ | |
| 378 | 406 | "old-registry.example.com/postgres:18", | |
| 407 | + | "registry.example.com/postgres:18", | |
| 379 | 408 | "registry.example.com/redis:7", | |
| 380 | 409 | "rabbitmq:3", | |
| 381 | 410 | "docker.io/library/nginx:latest", | |
| @@ -398,6 +427,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||
| 398 | 427 | require.NoError(t, err) | |
| 399 | 428 | text := string(content) | |
| 400 | 429 | assert.Contains(t, text, `registry.example.com/postgres:18`) | |
| 430 | + | assert.Equal(t, 1, strings.Count(text, `registry.example.com/postgres:18`)) | |
| 401 | 431 | assert.Contains(t, text, `registry.example.com/redis:7`) | |
| 402 | 432 | assert.Contains(t, text, `rabbitmq:3`) | |
| 403 | 433 | assert.Contains(t, text, `docker.io/library/nginx:latest`) | |
| @@ -1554,6 +1584,62 @@ func TestService_AddAttachment(t *testing.T) { | |||
| 1554 | 1584 | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | |
| 1555 | 1585 | }) | |
| 1556 | 1586 | ||
| 1587 | + | t.Run("rejects canonical duplicate when existing legacy ref matches current ref", func(t *testing.T) { | |
| 1588 | + | tmpDir := t.TempDir() | |
| 1589 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1590 | + | initialConfig := `[server] | |
| 1591 | + | gordon_domain = "registry.example.com" | |
| 1592 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 1593 | + | ||
| 1594 | + | [attachments] | |
| 1595 | + | "app.example.com" = ["old-registry.example.com/postgres:18"] | |
| 1596 | + | ` | |
| 1597 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1598 | + | require.NoError(t, err) | |
| 1599 | + | ||
| 1600 | + | v := viper.New() | |
| 1601 | + | v.SetConfigFile(configFile) | |
| 1602 | + | require.NoError(t, v.ReadInConfig()) | |
| 1603 | + | ||
| 1604 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1605 | + | svc := NewService(v, eventBus) | |
| 1606 | + | ctx := testContext() | |
| 1607 | + | require.NoError(t, svc.Load(ctx)) | |
| 1608 | + | ||
| 1609 | + | err = svc.AddAttachment(ctx, "app.example.com", "registry.example.com/postgres:18") | |
| 1610 | + | ||
| 1611 | + | assert.ErrorIs(t, err, domain.ErrAttachmentExists) | |
| 1612 | + | assert.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |
| 1613 | + | }) | |
| 1614 | + | ||
| 1615 | + | t.Run("rejects canonical duplicate when existing current ref matches legacy ref", func(t *testing.T) { | |
| 1616 | + | tmpDir := t.TempDir() | |
| 1617 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 1618 | + | initialConfig := `[server] | |
| 1619 | + | gordon_domain = "registry.example.com" | |
| 1620 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 1621 | + | ||
| 1622 | + | [attachments] | |
| 1623 | + | "app.example.com" = ["registry.example.com/postgres:18"] | |
| 1624 | + | ` | |
| 1625 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |
| 1626 | + | require.NoError(t, err) | |
| 1627 | + | ||
| 1628 | + | v := viper.New() | |
| 1629 | + | v.SetConfigFile(configFile) | |
| 1630 | + | require.NoError(t, v.ReadInConfig()) | |
| 1631 | + | ||
| 1632 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1633 | + | svc := NewService(v, eventBus) | |
| 1634 | + | ctx := testContext() | |
| 1635 | + | require.NoError(t, svc.Load(ctx)) | |
| 1636 | + | ||
| 1637 | + | err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") | |
| 1638 | + | ||
| 1639 | + | assert.ErrorIs(t, err, domain.ErrAttachmentExists) | |
| 1640 | + | assert.Equal(t, []string{"registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |
| 1641 | + | }) | |
| 1642 | + | ||
| 1557 | 1643 | t.Run("duplicate attachment", func(t *testing.T) { | |
| 1558 | 1644 | v := viper.New() | |
| 1559 | 1645 | v.Set("attachments", map[string]interface{}{ | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | + | "strings" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -339,6 +340,31 @@ legacy_registry_domains = ["old-registry.example.com", "old-registry.example.com | |||||
| 339 | assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) | 340 | assert.Contains(t, text, `"external.example.com" = { image = "docker.io/library/nginx:latest", https = true }`) | ||
| 340 | } | 341 | } | ||
| 341 | 342 | ||||
| 343 | + | func TestCanonicalAttachmentsForSave(t *testing.T) { | |||
| 344 | + | attachments := map[string][]string{ | |||
| 345 | + | "app.example.com": { | |||
| 346 | + | "old-registry.example.com/postgres:18", | |||
| 347 | + | "registry.example.com/postgres:18", | |||
| 348 | + | "rabbitmq:3", | |||
| 349 | + | "docker.io/library/nginx:latest", | |||
| 350 | + | }, | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | saved := canonicalAttachmentsForSave(attachments, "registry.example.com", []string{"old-registry.example.com"}) | |||
| 354 | + | ||||
| 355 | + | assert.Equal(t, []string{ | |||
| 356 | + | "registry.example.com/postgres:18", | |||
| 357 | + | "rabbitmq:3", | |||
| 358 | + | "docker.io/library/nginx:latest", | |||
| 359 | + | }, saved["app.example.com"]) | |||
| 360 | + | assert.Equal(t, []string{ | |||
| 361 | + | "old-registry.example.com/postgres:18", | |||
| 362 | + | "registry.example.com/postgres:18", | |||
| 363 | + | "rabbitmq:3", | |||
| 364 | + | "docker.io/library/nginx:latest", | |||
| 365 | + | }, attachments["app.example.com"]) | |||
| 366 | + | } | |||
| 367 | + | ||||
| 342 | func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | 368 | func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | ||
| 343 | tmpDir := t.TempDir() | 369 | tmpDir := t.TempDir() | ||
| 344 | configFile := filepath.Join(tmpDir, "gordon.toml") | 370 | configFile := filepath.Join(tmpDir, "gordon.toml") | ||
| @@ -349,6 +375,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||||
| 349 | [attachments] | 375 | [attachments] | ||
| 350 | "app.example.com" = [ | 376 | "app.example.com" = [ | ||
| 351 | "old-registry.example.com/postgres:18", | 377 | "old-registry.example.com/postgres:18", | ||
| 378 | + | "registry.example.com/postgres:18", | |||
| 352 | "registry.example.com/redis:7", | 379 | "registry.example.com/redis:7", | ||
| 353 | "rabbitmq:3", | 380 | "rabbitmq:3", | ||
| 354 | "docker.io/library/nginx:latest", | 381 | "docker.io/library/nginx:latest", | ||
| @@ -368,6 +395,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||||
| 368 | require.NoError(t, svc.Load(ctx)) | 395 | require.NoError(t, svc.Load(ctx)) | ||
| 369 | require.Equal(t, []string{ | 396 | require.Equal(t, []string{ | ||
| 370 | "old-registry.example.com/postgres:18", | 397 | "old-registry.example.com/postgres:18", | ||
| 398 | + | "registry.example.com/postgres:18", | |||
| 371 | "registry.example.com/redis:7", | 399 | "registry.example.com/redis:7", | ||
| 372 | "rabbitmq:3", | 400 | "rabbitmq:3", | ||
| 373 | "docker.io/library/nginx:latest", | 401 | "docker.io/library/nginx:latest", | ||
| @@ -376,6 +404,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||||
| 376 | require.NoError(t, svc.Save(ctx)) | 404 | require.NoError(t, svc.Save(ctx)) | ||
| 377 | require.Equal(t, []string{ | 405 | require.Equal(t, []string{ | ||
| 378 | "old-registry.example.com/postgres:18", | 406 | "old-registry.example.com/postgres:18", | ||
| 407 | + | "registry.example.com/postgres:18", | |||
| 379 | "registry.example.com/redis:7", | 408 | "registry.example.com/redis:7", | ||
| 380 | "rabbitmq:3", | 409 | "rabbitmq:3", | ||
| 381 | "docker.io/library/nginx:latest", | 410 | "docker.io/library/nginx:latest", | ||
| @@ -398,6 +427,7 @@ func TestService_SaveCanonicalizesAttachmentImages(t *testing.T) { | |||||
| 398 | require.NoError(t, err) | 427 | require.NoError(t, err) | ||
| 399 | text := string(content) | 428 | text := string(content) | ||
| 400 | assert.Contains(t, text, `registry.example.com/postgres:18`) | 429 | assert.Contains(t, text, `registry.example.com/postgres:18`) | ||
| 430 | + | assert.Equal(t, 1, strings.Count(text, `registry.example.com/postgres:18`)) | |||
| 401 | assert.Contains(t, text, `registry.example.com/redis:7`) | 431 | assert.Contains(t, text, `registry.example.com/redis:7`) | ||
| 402 | assert.Contains(t, text, `rabbitmq:3`) | 432 | assert.Contains(t, text, `rabbitmq:3`) | ||
| 403 | assert.Contains(t, text, `docker.io/library/nginx:latest`) | 433 | assert.Contains(t, text, `docker.io/library/nginx:latest`) | ||
| @@ -1554,6 +1584,62 @@ func TestService_AddAttachment(t *testing.T) { | |||||
| 1554 | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | 1584 | assert.NotContains(t, text, `old-registry.example.com/postgres:18`) | ||
| 1555 | }) | 1585 | }) | ||
| 1556 | 1586 | ||||
| 1587 | + | t.Run("rejects canonical duplicate when existing legacy ref matches current ref", func(t *testing.T) { | |||
| 1588 | + | tmpDir := t.TempDir() | |||
| 1589 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1590 | + | initialConfig := `[server] | |||
| 1591 | + | gordon_domain = "registry.example.com" | |||
| 1592 | + | legacy_registry_domains = ["old-registry.example.com"] | |||
| 1593 | + | ||||
| 1594 | + | [attachments] | |||
| 1595 | + | "app.example.com" = ["old-registry.example.com/postgres:18"] | |||
| 1596 | + | ` | |||
| 1597 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1598 | + | require.NoError(t, err) | |||
| 1599 | + | ||||
| 1600 | + | v := viper.New() | |||
| 1601 | + | v.SetConfigFile(configFile) | |||
| 1602 | + | require.NoError(t, v.ReadInConfig()) | |||
| 1603 | + | ||||
| 1604 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1605 | + | svc := NewService(v, eventBus) | |||
| 1606 | + | ctx := testContext() | |||
| 1607 | + | require.NoError(t, svc.Load(ctx)) | |||
| 1608 | + | ||||
| 1609 | + | err = svc.AddAttachment(ctx, "app.example.com", "registry.example.com/postgres:18") | |||
| 1610 | + | ||||
| 1611 | + | assert.ErrorIs(t, err, domain.ErrAttachmentExists) | |||
| 1612 | + | assert.Equal(t, []string{"old-registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |||
| 1613 | + | }) | |||
| 1614 | + | ||||
| 1615 | + | t.Run("rejects canonical duplicate when existing current ref matches legacy ref", func(t *testing.T) { | |||
| 1616 | + | tmpDir := t.TempDir() | |||
| 1617 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 1618 | + | initialConfig := `[server] | |||
| 1619 | + | gordon_domain = "registry.example.com" | |||
| 1620 | + | legacy_registry_domains = ["old-registry.example.com"] | |||
| 1621 | + | ||||
| 1622 | + | [attachments] | |||
| 1623 | + | "app.example.com" = ["registry.example.com/postgres:18"] | |||
| 1624 | + | ` | |||
| 1625 | + | err := os.WriteFile(configFile, []byte(initialConfig), 0600) | |||
| 1626 | + | require.NoError(t, err) | |||
| 1627 | + | ||||
| 1628 | + | v := viper.New() | |||
| 1629 | + | v.SetConfigFile(configFile) | |||
| 1630 | + | require.NoError(t, v.ReadInConfig()) | |||
| 1631 | + | ||||
| 1632 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1633 | + | svc := NewService(v, eventBus) | |||
| 1634 | + | ctx := testContext() | |||
| 1635 | + | require.NoError(t, svc.Load(ctx)) | |||
| 1636 | + | ||||
| 1637 | + | err = svc.AddAttachment(ctx, "app.example.com", "old-registry.example.com/postgres:18") | |||
| 1638 | + | ||||
| 1639 | + | assert.ErrorIs(t, err, domain.ErrAttachmentExists) | |||
| 1640 | + | assert.Equal(t, []string{"registry.example.com/postgres:18"}, svc.GetConfig().Attachments["app.example.com"]) | |||
| 1641 | + | }) | |||
| 1642 | + | ||||
| 1557 | t.Run("duplicate attachment", func(t *testing.T) { | 1643 | t.Run("duplicate attachment", func(t *testing.T) { | ||
| 1558 | v := viper.New() | 1644 | v := viper.New() | ||
| 1559 | v.Set("attachments", map[string]interface{}{ | 1645 | v.Set("attachments", map[string]interface{}{ | ||
M internal/usecase/container/autoroute.go +19 -4
| @@ -22,6 +22,11 @@ type EnvFileExtractor interface { | |||
| 22 | 22 | ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | + | type registryDomainsProvider interface { | |
| 26 | + | GetRegistryDomain() string | |
| 27 | + | GetLegacyRegistryDomains() []string | |
| 28 | + | } | |
| 29 | + | ||
| 25 | 30 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | |
| 26 | 31 | type AutoRouteHandler struct { | |
| 27 | 32 | configSvc in.ConfigService | |
| @@ -133,15 +138,24 @@ func (h *AutoRouteHandler) buildImageName(name, reference string) string { | |||
| 133 | 138 | return fmt.Sprintf("%s:%s", name, reference) | |
| 134 | 139 | } | |
| 135 | 140 | ||
| 141 | + | func (h *AutoRouteHandler) registryDomains() (string, []string) { | |
| 142 | + | provider, ok := h.configSvc.(registryDomainsProvider) | |
| 143 | + | if !ok { | |
| 144 | + | return h.registryDomain, append([]string(nil), h.legacyRegistryDomains...) | |
| 145 | + | } | |
| 146 | + | return provider.GetRegistryDomain(), provider.GetLegacyRegistryDomains() | |
| 147 | + | } | |
| 148 | + | ||
| 136 | 149 | // buildFullImageRef constructs a fully qualified image reference with registry domain. | |
| 137 | 150 | // This is needed for Docker operations since images are stored with the registry prefix. | |
| 138 | 151 | func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | |
| 139 | - | if h.registryDomain == "" { | |
| 152 | + | registryDomain, _ := h.registryDomains() | |
| 153 | + | if registryDomain == "" { | |
| 140 | 154 | return imageName | |
| 141 | 155 | } | |
| 142 | 156 | ||
| 143 | 157 | // Don't prefix if already has registry domain | |
| 144 | - | if strings.HasPrefix(imageName, h.registryDomain+"/") { | |
| 158 | + | if strings.HasPrefix(imageName, registryDomain+"/") { | |
| 145 | 159 | return imageName | |
| 146 | 160 | } | |
| 147 | 161 | ||
| @@ -154,7 +168,7 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | |||
| 154 | 168 | return imageName | |
| 155 | 169 | } | |
| 156 | 170 | ||
| 157 | - | return fmt.Sprintf("%s/%s", h.registryDomain, imageName) | |
| 171 | + | return fmt.Sprintf("%s/%s", registryDomain, imageName) | |
| 158 | 172 | } | |
| 159 | 173 | ||
| 160 | 174 | // processRoutes processes each domain and creates/updates routes. | |
| @@ -211,11 +225,12 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||
| 211 | 225 | HTTPS: true, | |
| 212 | 226 | } | |
| 213 | 227 | ||
| 228 | + | registryDomain, legacyRegistryDomains := h.registryDomains() | |
| 214 | 229 | existingRoutes := h.configSvc.GetRoutes(ctx) | |
| 215 | 230 | for _, existing := range existingRoutes { | |
| 216 | 231 | if existing.Domain == routeDomain { | |
| 217 | 232 | route.HTTPS = existing.HTTPS | |
| 218 | - | if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) { | |
| 233 | + | if auto.ExtractRepoName(existing.Image, registryDomain, legacyRegistryDomains...) != auto.ExtractRepoName(imageName, registryDomain, legacyRegistryDomains...) { | |
| 219 | 234 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | |
| 220 | 235 | return false | |
| 221 | 236 | } | |
| @@ -22,6 +22,11 @@ type EnvFileExtractor interface { | |||||
| 22 | ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) | 22 | ExtractEnvFileFromImage(ctx context.Context, imageRef, envFilePath string) ([]byte, error) | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | + | type registryDomainsProvider interface { | |||
| 26 | + | GetRegistryDomain() string | |||
| 27 | + | GetLegacyRegistryDomains() []string | |||
| 28 | + | } | |||
| 29 | + | ||||
| 25 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | 30 | // AutoRouteHandler handles image.pushed events for auto-route from labels. | ||
| 26 | type AutoRouteHandler struct { | 31 | type AutoRouteHandler struct { | ||
| 27 | configSvc in.ConfigService | 32 | configSvc in.ConfigService | ||
| @@ -133,15 +138,24 @@ func (h *AutoRouteHandler) buildImageName(name, reference string) string { | |||||
| 133 | return fmt.Sprintf("%s:%s", name, reference) | 138 | return fmt.Sprintf("%s:%s", name, reference) | ||
| 134 | } | 139 | } | ||
| 135 | 140 | ||||
| 141 | + | func (h *AutoRouteHandler) registryDomains() (string, []string) { | |||
| 142 | + | provider, ok := h.configSvc.(registryDomainsProvider) | |||
| 143 | + | if !ok { | |||
| 144 | + | return h.registryDomain, append([]string(nil), h.legacyRegistryDomains...) | |||
| 145 | + | } | |||
| 146 | + | return provider.GetRegistryDomain(), provider.GetLegacyRegistryDomains() | |||
| 147 | + | } | |||
| 148 | + | ||||
| 136 | // buildFullImageRef constructs a fully qualified image reference with registry domain. | 149 | // buildFullImageRef constructs a fully qualified image reference with registry domain. | ||
| 137 | // This is needed for Docker operations since images are stored with the registry prefix. | 150 | // This is needed for Docker operations since images are stored with the registry prefix. | ||
| 138 | func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | 151 | func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | ||
| 139 | - | if h.registryDomain == "" { | 152 | + | registryDomain, _ := h.registryDomains() |
| 153 | + | if registryDomain == "" { | |||
| 140 | return imageName | 154 | return imageName | ||
| 141 | } | 155 | } | ||
| 142 | 156 | ||||
| 143 | // Don't prefix if already has registry domain | 157 | // Don't prefix if already has registry domain | ||
| 144 | - | if strings.HasPrefix(imageName, h.registryDomain+"/") { | 158 | + | if strings.HasPrefix(imageName, registryDomain+"/") { |
| 145 | return imageName | 159 | return imageName | ||
| 146 | } | 160 | } | ||
| 147 | 161 | ||||
| @@ -154,7 +168,7 @@ func (h *AutoRouteHandler) buildFullImageRef(imageName string) string { | |||||
| 154 | return imageName | 168 | return imageName | ||
| 155 | } | 169 | } | ||
| 156 | 170 | ||||
| 157 | - | return fmt.Sprintf("%s/%s", h.registryDomain, imageName) | 171 | + | return fmt.Sprintf("%s/%s", registryDomain, imageName) |
| 158 | } | 172 | } | ||
| 159 | 173 | ||||
| 160 | // processRoutes processes each domain and creates/updates routes. | 174 | // processRoutes processes each domain and creates/updates routes. | ||
| @@ -211,11 +225,12 @@ func (h *AutoRouteHandler) createOrUpdateRoute(ctx context.Context, routeDomain, | |||||
| 211 | HTTPS: true, | 225 | HTTPS: true, | ||
| 212 | } | 226 | } | ||
| 213 | 227 | ||||
| 228 | + | registryDomain, legacyRegistryDomains := h.registryDomains() | |||
| 214 | existingRoutes := h.configSvc.GetRoutes(ctx) | 229 | existingRoutes := h.configSvc.GetRoutes(ctx) | ||
| 215 | for _, existing := range existingRoutes { | 230 | for _, existing := range existingRoutes { | ||
| 216 | if existing.Domain == routeDomain { | 231 | if existing.Domain == routeDomain { | ||
| 217 | route.HTTPS = existing.HTTPS | 232 | route.HTTPS = existing.HTTPS | ||
| 218 | - | if auto.ExtractRepoName(existing.Image, h.registryDomain, h.legacyRegistryDomains...) != auto.ExtractRepoName(imageName, h.registryDomain, h.legacyRegistryDomains...) { | 233 | + | if auto.ExtractRepoName(existing.Image, registryDomain, legacyRegistryDomains...) != auto.ExtractRepoName(imageName, registryDomain, legacyRegistryDomains...) { |
| 219 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | 234 | log.Warn().Str("domain", routeDomain).Str("existing_image", existing.Image).Str("image", imageName).Msg("auto-route update rejected due to repository ownership mismatch") | ||
| 220 | return false | 235 | return false | ||
| 221 | } | 236 | } | ||
M internal/usecase/container/autoroute_test.go +46 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "testing" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/zerowrap" | |
| 13 | + | "github.com/spf13/viper" | |
| 13 | 14 | "github.com/stretchr/testify/assert" | |
| 14 | 15 | "github.com/stretchr/testify/mock" | |
| 15 | 16 | "github.com/stretchr/testify/require" | |
| @@ -18,6 +19,7 @@ import ( | |||
| 18 | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 19 | 20 | "github.com/bnema/gordon/internal/domain" | |
| 20 | 21 | "github.com/bnema/gordon/internal/usecase/auto" | |
| 22 | + | configusecase "github.com/bnema/gordon/internal/usecase/config" | |
| 21 | 23 | ) | |
| 22 | 24 | ||
| 23 | 25 | // domainToEnvFileName tests | |
| @@ -856,6 +858,50 @@ func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryH | |||
| 856 | 858 | assert.False(t, created) | |
| 857 | 859 | } | |
| 858 | 860 | ||
| 861 | + | func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) { | |
| 862 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 863 | + | tmpDir := t.TempDir() | |
| 864 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |
| 865 | + | require.NoError(t, os.WriteFile(configFile, []byte(`[server] | |
| 866 | + | gordon_domain = "old-registry.example.com" | |
| 867 | + | ||
| 868 | + | [routes] | |
| 869 | + | "app.example.com" = "old-registry.example.com/app:v1" | |
| 870 | + | `), 0600)) | |
| 871 | + | ||
| 872 | + | v := viper.New() | |
| 873 | + | v.SetConfigFile(configFile) | |
| 874 | + | require.NoError(t, v.ReadInConfig()) | |
| 875 | + | ||
| 876 | + | configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t)) | |
| 877 | + | require.NoError(t, configSvc.Load(ctx)) | |
| 878 | + | ||
| 879 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 880 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com") | |
| 881 | + | ||
| 882 | + | require.NoError(t, os.WriteFile(configFile, []byte(`[server] | |
| 883 | + | gordon_domain = "new-registry.example.com" | |
| 884 | + | legacy_registry_domains = ["old-registry.example.com"] | |
| 885 | + | ||
| 886 | + | [routes] | |
| 887 | + | "app.example.com" = "old-registry.example.com/app:v1" | |
| 888 | + | `), 0600)) | |
| 889 | + | require.NoError(t, configSvc.Reload(ctx)) | |
| 890 | + | ||
| 891 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |
| 892 | + | Domain: "app.example.com", | |
| 893 | + | Image: "new-registry.example.com/app:v2", | |
| 894 | + | HTTPS: true, | |
| 895 | + | }).Return(&domain.Container{ID: "c1"}, nil) | |
| 896 | + | ||
| 897 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) | |
| 898 | + | assert.False(t, created) | |
| 899 | + | ||
| 900 | + | route, err := configSvc.GetRoute(ctx, "app.example.com") | |
| 901 | + | require.NoError(t, err) | |
| 902 | + | assert.Equal(t, "new-registry.example.com/app:v2", route.Image) | |
| 903 | + | } | |
| 904 | + | ||
| 859 | 905 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | |
| 860 | 906 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |
| 861 | 907 | configSvc := inmocks.NewMockConfigService(t) | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "testing" | 10 | "testing" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/zerowrap" | 12 | "github.com/bnema/zerowrap" | ||
| 13 | + | "github.com/spf13/viper" | |||
| 13 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/mock" | 15 | "github.com/stretchr/testify/mock" | ||
| 15 | "github.com/stretchr/testify/require" | 16 | "github.com/stretchr/testify/require" | ||
| @@ -18,6 +19,7 @@ import ( | |||||
| 18 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | 19 | "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 19 | "github.com/bnema/gordon/internal/domain" | 20 | "github.com/bnema/gordon/internal/domain" | ||
| 20 | "github.com/bnema/gordon/internal/usecase/auto" | 21 | "github.com/bnema/gordon/internal/usecase/auto" | ||
| 22 | + | configusecase "github.com/bnema/gordon/internal/usecase/config" | |||
| 21 | ) | 23 | ) | ||
| 22 | 24 | ||||
| 23 | // domainToEnvFileName tests | 25 | // domainToEnvFileName tests | ||
| @@ -856,6 +858,50 @@ func TestAutoRouteHandler_ExistingDomain_SameRepoAcrossLegacyAndCurrentRegistryH | |||||
| 856 | assert.False(t, created) | 858 | assert.False(t, created) | ||
| 857 | } | 859 | } | ||
| 858 | 860 | ||||
| 861 | + | func TestAutoRouteHandler_UsesRefreshedRegistryDomainsAfterReload(t *testing.T) { | |||
| 862 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | |||
| 863 | + | tmpDir := t.TempDir() | |||
| 864 | + | configFile := filepath.Join(tmpDir, "gordon.toml") | |||
| 865 | + | require.NoError(t, os.WriteFile(configFile, []byte(`[server] | |||
| 866 | + | gordon_domain = "old-registry.example.com" | |||
| 867 | + | ||||
| 868 | + | [routes] | |||
| 869 | + | "app.example.com" = "old-registry.example.com/app:v1" | |||
| 870 | + | `), 0600)) | |||
| 871 | + | ||||
| 872 | + | v := viper.New() | |||
| 873 | + | v.SetConfigFile(configFile) | |||
| 874 | + | require.NoError(t, v.ReadInConfig()) | |||
| 875 | + | ||||
| 876 | + | configSvc := configusecase.NewService(v, mocks.NewMockEventPublisher(t)) | |||
| 877 | + | require.NoError(t, configSvc.Load(ctx)) | |||
| 878 | + | ||||
| 879 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 880 | + | handler := NewAutoRouteHandler(ctx, configSvc, containerSvc, mocks.NewMockBlobStorage(t), "old-registry.example.com") | |||
| 881 | + | ||||
| 882 | + | require.NoError(t, os.WriteFile(configFile, []byte(`[server] | |||
| 883 | + | gordon_domain = "new-registry.example.com" | |||
| 884 | + | legacy_registry_domains = ["old-registry.example.com"] | |||
| 885 | + | ||||
| 886 | + | [routes] | |||
| 887 | + | "app.example.com" = "old-registry.example.com/app:v1" | |||
| 888 | + | `), 0600)) | |||
| 889 | + | require.NoError(t, configSvc.Reload(ctx)) | |||
| 890 | + | ||||
| 891 | + | containerSvc.EXPECT().Deploy(mock.Anything, domain.Route{ | |||
| 892 | + | Domain: "app.example.com", | |||
| 893 | + | Image: "new-registry.example.com/app:v2", | |||
| 894 | + | HTTPS: true, | |||
| 895 | + | }).Return(&domain.Container{ID: "c1"}, nil) | |||
| 896 | + | ||||
| 897 | + | created := handler.createOrUpdateRoute(context.Background(), "app.example.com", "new-registry.example.com/app:v2", []string{"*.example.com"}) | |||
| 898 | + | assert.False(t, created) | |||
| 899 | + | ||||
| 900 | + | route, err := configSvc.GetRoute(ctx, "app.example.com") | |||
| 901 | + | require.NoError(t, err) | |||
| 902 | + | assert.Equal(t, "new-registry.example.com/app:v2", route.Image) | |||
| 903 | + | } | |||
| 904 | + | ||||
| 859 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | 905 | func TestAutoRouteHandler_ExistingDomain_DifferentRepo(t *testing.T) { | ||
| 860 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | 906 | ctx := zerowrap.WithCtx(context.Background(), zerowrap.Default()) | ||
| 861 | configSvc := inmocks.NewMockConfigService(t) | 907 | configSvc := inmocks.NewMockConfigService(t) | ||
M internal/usecase/container/service.go +6 -4
| @@ -1334,8 +1334,10 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||
| 1334 | 1334 | return results | |
| 1335 | 1335 | } | |
| 1336 | 1336 | ||
| 1337 | - | // stripRegistryPrefix removes the configured registry domain prefix from an image reference. | |
| 1338 | - | // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com". | |
| 1337 | + | // stripRegistryPrefix removes the configured current or legacy Gordon registry | |
| 1338 | + | // domain prefix from an image reference. For example, | |
| 1339 | + | // "reg.example.com/myapp:latest" becomes "myapp:latest" when | |
| 1340 | + | // "reg.example.com" is a current or legacy Gordon registry domain. | |
| 1339 | 1341 | func (s *Service) stripRegistryPrefix(image string) string { | |
| 1340 | 1342 | s.mu.RLock() | |
| 1341 | 1343 | cfg := s.config | |
| @@ -3365,8 +3367,8 @@ func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomai | |||
| 3365 | 3367 | ||
| 3366 | 3368 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | |
| 3367 | 3369 | localPrefix := localRegistry + "/" | |
| 3368 | - | if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok { | |
| 3369 | - | return localPrefix + remainder | |
| 3370 | + | if strings.HasPrefix(imageRef, localPrefix) { | |
| 3371 | + | return imageRef | |
| 3370 | 3372 | } | |
| 3371 | 3373 | ||
| 3372 | 3374 | if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { | |
| @@ -1334,8 +1334,10 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||||
| 1334 | return results | 1334 | return results | ||
| 1335 | } | 1335 | } | ||
| 1336 | 1336 | ||||
| 1337 | - | // stripRegistryPrefix removes the configured registry domain prefix from an image reference. | 1337 | + | // stripRegistryPrefix removes the configured current or legacy Gordon registry |
| 1338 | - | // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com". | 1338 | + | // domain prefix from an image reference. For example, |
| 1339 | + | // "reg.example.com/myapp:latest" becomes "myapp:latest" when | |||
| 1340 | + | // "reg.example.com" is a current or legacy Gordon registry domain. | |||
| 1339 | func (s *Service) stripRegistryPrefix(image string) string { | 1341 | func (s *Service) stripRegistryPrefix(image string) string { | ||
| 1340 | s.mu.RLock() | 1342 | s.mu.RLock() | ||
| 1341 | cfg := s.config | 1343 | cfg := s.config | ||
| @@ -3365,8 +3367,8 @@ func rewriteToLocalRegistry(imageRef, registryDomain string, legacyRegistryDomai | |||||
| 3365 | 3367 | ||||
| 3366 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | 3368 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | ||
| 3367 | localPrefix := localRegistry + "/" | 3369 | localPrefix := localRegistry + "/" | ||
| 3368 | - | if remainder, ok := strings.CutPrefix(imageRef, localPrefix); ok { | 3370 | + | if strings.HasPrefix(imageRef, localPrefix) { |
| 3369 | - | return localPrefix + remainder | 3371 | + | return imageRef |
| 3370 | } | 3372 | } | ||
| 3371 | 3373 | ||||
| 3372 | if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { | 3374 | if domain.IsGordonRegistryImageRef(imageRef, registryDomain, legacyRegistryDomains) { | ||