fix: address code review issues
- Fix unreachable code in truncateImage/truncateNetwork functions - Optimize N+1 query in ListRoutesWithDetails by batching attachment lookup - Restore removed registry handler tests (NotFound, NestedName, PutManifest, GetBlob) - Add unit tests for truncation functions
- commit
- 0879deee975c9d7eafde832b8814c27b40279416
- author
- brice · · 2026-01-17 13:38 UTC
- parent
- bca4041
- signature
- unsigned
5 files · +392 -32
M internal/adapters/in/cli/routes.go +19 -31
| @@ -34,33 +34,24 @@ func truncateImage(image string, maxLen int) string { | |||
| 34 | 34 | digest = digest[:12] | |
| 35 | 35 | } | |
| 36 | 36 | short := fmt.Sprintf("%s@sha256:%s", name, digest) | |
| 37 | - | if len(short) > maxLen { | |
| 38 | - | // Truncate from the end to maintain valid reference format | |
| 39 | - | if maxLen <= 3 { | |
| 40 | - | if len(short) < maxLen { | |
| 41 | - | return short | |
| 42 | - | } | |
| 43 | - | return short[:maxLen] | |
| 44 | - | } | |
| 45 | - | if len(short) <= maxLen { | |
| 46 | - | return short | |
| 47 | - | } | |
| 48 | - | return short[:maxLen-3] + "..." | |
| 37 | + | if len(short) <= maxLen { | |
| 38 | + | return short | |
| 49 | 39 | } | |
| 50 | - | return short | |
| 40 | + | // Truncate from the end to maintain valid reference format | |
| 41 | + | if maxLen <= 3 { | |
| 42 | + | return short[:maxLen] | |
| 43 | + | } | |
| 44 | + | return short[:maxLen-3] + "..." | |
| 51 | 45 | } | |
| 52 | 46 | ||
| 53 | 47 | // Regular tag: truncate if needed | |
| 54 | - | if len(image) > maxLen { | |
| 55 | - | if maxLen <= 3 { | |
| 56 | - | if len(image) < maxLen { | |
| 57 | - | return image | |
| 58 | - | } | |
| 59 | - | return image[:maxLen] | |
| 60 | - | } | |
| 61 | - | return image[:maxLen-3] + "..." | |
| 48 | + | if len(image) <= maxLen { | |
| 49 | + | return image | |
| 50 | + | } | |
| 51 | + | if maxLen <= 3 { | |
| 52 | + | return image[:maxLen] | |
| 62 | 53 | } | |
| 63 | - | return image | |
| 54 | + | return image[:maxLen-3] + "..." | |
| 64 | 55 | } | |
| 65 | 56 | ||
| 66 | 57 | func truncateNetwork(network string, maxLen int) string { | |
| @@ -70,16 +61,13 @@ func truncateNetwork(network string, maxLen int) string { | |||
| 70 | 61 | if maxLen <= 0 { | |
| 71 | 62 | return "" | |
| 72 | 63 | } | |
| 73 | - | if len(network) > maxLen { | |
| 74 | - | if maxLen <= 3 { | |
| 75 | - | if len(network) < maxLen { | |
| 76 | - | return network | |
| 77 | - | } | |
| 78 | - | return network[:maxLen] | |
| 79 | - | } | |
| 80 | - | return network[:maxLen-3] + "..." | |
| 64 | + | if len(network) <= maxLen { | |
| 65 | + | return network | |
| 66 | + | } | |
| 67 | + | if maxLen <= 3 { | |
| 68 | + | return network[:maxLen] | |
| 81 | 69 | } | |
| 82 | - | return network | |
| 70 | + | return network[:maxLen-3] + "..." | |
| 83 | 71 | } | |
| 84 | 72 | ||
| 85 | 73 | // newRoutesCmd creates the routes command group. | |
| @@ -34,33 +34,24 @@ func truncateImage(image string, maxLen int) string { | |||||
| 34 | digest = digest[:12] | 34 | digest = digest[:12] | ||
| 35 | } | 35 | } | ||
| 36 | short := fmt.Sprintf("%s@sha256:%s", name, digest) | 36 | short := fmt.Sprintf("%s@sha256:%s", name, digest) | ||
| 37 | - | if len(short) > maxLen { | 37 | + | if len(short) <= maxLen { |
| 38 | - | // Truncate from the end to maintain valid reference format | 38 | + | return short |
| 39 | - | if maxLen <= 3 { | |||
| 40 | - | if len(short) < maxLen { | |||
| 41 | - | return short | |||
| 42 | - | } | |||
| 43 | - | return short[:maxLen] | |||
| 44 | - | } | |||
| 45 | - | if len(short) <= maxLen { | |||
| 46 | - | return short | |||
| 47 | - | } | |||
| 48 | - | return short[:maxLen-3] + "..." | |||
| 49 | } | 39 | } | ||
| 50 | - | return short | 40 | + | // Truncate from the end to maintain valid reference format |
| 41 | + | if maxLen <= 3 { | |||
| 42 | + | return short[:maxLen] | |||
| 43 | + | } | |||
| 44 | + | return short[:maxLen-3] + "..." | |||
| 51 | } | 45 | } | ||
| 52 | 46 | ||||
| 53 | // Regular tag: truncate if needed | 47 | // Regular tag: truncate if needed | ||
| 54 | - | if len(image) > maxLen { | 48 | + | if len(image) <= maxLen { |
| 55 | - | if maxLen <= 3 { | 49 | + | return image |
| 56 | - | if len(image) < maxLen { | 50 | + | } |
| 57 | - | return image | 51 | + | if maxLen <= 3 { |
| 58 | - | } | 52 | + | return image[:maxLen] |
| 59 | - | return image[:maxLen] | |||
| 60 | - | } | |||
| 61 | - | return image[:maxLen-3] + "..." | |||
| 62 | } | 53 | } | ||
| 63 | - | return image | 54 | + | return image[:maxLen-3] + "..." |
| 64 | } | 55 | } | ||
| 65 | 56 | ||||
| 66 | func truncateNetwork(network string, maxLen int) string { | 57 | func truncateNetwork(network string, maxLen int) string { | ||
| @@ -70,16 +61,13 @@ func truncateNetwork(network string, maxLen int) string { | |||||
| 70 | if maxLen <= 0 { | 61 | if maxLen <= 0 { | ||
| 71 | return "" | 62 | return "" | ||
| 72 | } | 63 | } | ||
| 73 | - | if len(network) > maxLen { | 64 | + | if len(network) <= maxLen { |
| 74 | - | if maxLen <= 3 { | 65 | + | return network |
| 75 | - | if len(network) < maxLen { | 66 | + | } |
| 76 | - | return network | 67 | + | if maxLen <= 3 { |
| 77 | - | } | 68 | + | return network[:maxLen] |
| 78 | - | return network[:maxLen] | |||
| 79 | - | } | |||
| 80 | - | return network[:maxLen-3] + "..." | |||
| 81 | } | 69 | } | ||
| 82 | - | return network | 70 | + | return network[:maxLen-3] + "..." |
| 83 | } | 71 | } | ||
| 84 | 72 | ||||
| 85 | // newRoutesCmd creates the routes command group. | 73 | // newRoutesCmd creates the routes command group. | ||
A internal/adapters/in/cli/routes_test.go +181 -0
| @@ -0,0 +1,181 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestTruncateImage(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | image string | |
| 13 | + | maxLen int | |
| 14 | + | expected string | |
| 15 | + | }{ | |
| 16 | + | // Basic cases - no truncation needed | |
| 17 | + | { | |
| 18 | + | name: "short image fits", | |
| 19 | + | image: "nginx:latest", | |
| 20 | + | maxLen: 20, | |
| 21 | + | expected: "nginx:latest", | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "exact fit", | |
| 25 | + | image: "nginx:latest", | |
| 26 | + | maxLen: 12, | |
| 27 | + | expected: "nginx:latest", | |
| 28 | + | }, | |
| 29 | + | ||
| 30 | + | // Regular tag truncation | |
| 31 | + | { | |
| 32 | + | name: "truncate long tag with ellipsis", | |
| 33 | + | image: "registry.test.com/test:v1234567890", | |
| 34 | + | maxLen: 30, | |
| 35 | + | expected: "registry.test.com/test:v123...", | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "truncate very long image", | |
| 39 | + | image: "registry.example.com/organization/project/image:v1.2.3-beta.4", | |
| 40 | + | maxLen: 35, | |
| 41 | + | expected: "registry.example.com/organizatio...", | |
| 42 | + | }, | |
| 43 | + | ||
| 44 | + | // Digest truncation | |
| 45 | + | { | |
| 46 | + | name: "digest shortened to 12 chars", | |
| 47 | + | image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", | |
| 48 | + | maxLen: 50, | |
| 49 | + | expected: "myapp@sha256:a3ed95caeb02", | |
| 50 | + | }, | |
| 51 | + | { | |
| 52 | + | name: "digest truncated with ellipsis when too long", | |
| 53 | + | image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", | |
| 54 | + | maxLen: 35, | |
| 55 | + | expected: "registry.example.com/org/app@sha...", | |
| 56 | + | }, | |
| 57 | + | { | |
| 58 | + | name: "short digest fits", | |
| 59 | + | image: "app@sha256:abc123", | |
| 60 | + | maxLen: 30, | |
| 61 | + | expected: "app@sha256:abc123", | |
| 62 | + | }, | |
| 63 | + | ||
| 64 | + | // Edge cases | |
| 65 | + | { | |
| 66 | + | name: "maxLen zero returns empty", | |
| 67 | + | image: "nginx:latest", | |
| 68 | + | maxLen: 0, | |
| 69 | + | expected: "", | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | name: "maxLen negative returns empty", | |
| 73 | + | image: "nginx:latest", | |
| 74 | + | maxLen: -1, | |
| 75 | + | expected: "", | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "maxLen 3 or less - no ellipsis", | |
| 79 | + | image: "nginx:latest", | |
| 80 | + | maxLen: 3, | |
| 81 | + | expected: "ngi", | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "maxLen 4 - truncate with ellipsis", | |
| 85 | + | image: "nginx:latest", | |
| 86 | + | maxLen: 4, | |
| 87 | + | expected: "n...", | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "empty image", | |
| 91 | + | image: "", | |
| 92 | + | maxLen: 10, | |
| 93 | + | expected: "", | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | for _, tt := range tests { | |
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |
| 99 | + | result := truncateImage(tt.image, tt.maxLen) | |
| 100 | + | assert.Equal(t, tt.expected, result) | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestTruncateNetwork(t *testing.T) { | |
| 106 | + | tests := []struct { | |
| 107 | + | name string | |
| 108 | + | network string | |
| 109 | + | maxLen int | |
| 110 | + | expected string | |
| 111 | + | }{ | |
| 112 | + | // Basic cases | |
| 113 | + | { | |
| 114 | + | name: "short network fits", | |
| 115 | + | network: "gordon-net", | |
| 116 | + | maxLen: 20, | |
| 117 | + | expected: "gordon-net", | |
| 118 | + | }, | |
| 119 | + | { | |
| 120 | + | name: "exact fit", | |
| 121 | + | network: "gordon-net", | |
| 122 | + | maxLen: 10, | |
| 123 | + | expected: "gordon-net", | |
| 124 | + | }, | |
| 125 | + | ||
| 126 | + | // Truncation | |
| 127 | + | { | |
| 128 | + | name: "truncate long network with ellipsis", | |
| 129 | + | network: "gordon-my-very-long-network-name", | |
| 130 | + | maxLen: 20, | |
| 131 | + | expected: "gordon-my-very-lo...", | |
| 132 | + | }, | |
| 133 | + | ||
| 134 | + | // Special values | |
| 135 | + | { | |
| 136 | + | name: "empty returns dash", | |
| 137 | + | network: "", | |
| 138 | + | maxLen: 10, | |
| 139 | + | expected: "-", | |
| 140 | + | }, | |
| 141 | + | { | |
| 142 | + | name: "dash returns dash", | |
| 143 | + | network: "-", | |
| 144 | + | maxLen: 10, | |
| 145 | + | expected: "-", | |
| 146 | + | }, | |
| 147 | + | ||
| 148 | + | // Edge cases | |
| 149 | + | { | |
| 150 | + | name: "maxLen zero returns empty", | |
| 151 | + | network: "gordon-net", | |
| 152 | + | maxLen: 0, | |
| 153 | + | expected: "", | |
| 154 | + | }, | |
| 155 | + | { | |
| 156 | + | name: "maxLen negative returns empty", | |
| 157 | + | network: "gordon-net", | |
| 158 | + | maxLen: -1, | |
| 159 | + | expected: "", | |
| 160 | + | }, | |
| 161 | + | { | |
| 162 | + | name: "maxLen 3 or less - no ellipsis", | |
| 163 | + | network: "gordon-net", | |
| 164 | + | maxLen: 3, | |
| 165 | + | expected: "gor", | |
| 166 | + | }, | |
| 167 | + | { | |
| 168 | + | name: "maxLen 4 - truncate with ellipsis", | |
| 169 | + | network: "gordon-net", | |
| 170 | + | maxLen: 4, | |
| 171 | + | expected: "g...", | |
| 172 | + | }, | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | for _, tt := range tests { | |
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |
| 177 | + | result := truncateNetwork(tt.network, tt.maxLen) | |
| 178 | + | assert.Equal(t, tt.expected, result) | |
| 179 | + | }) | |
| 180 | + | } | |
| 181 | + | } | |
| @@ -0,0 +1,181 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestTruncateImage(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | image string | |||
| 13 | + | maxLen int | |||
| 14 | + | expected string | |||
| 15 | + | }{ | |||
| 16 | + | // Basic cases - no truncation needed | |||
| 17 | + | { | |||
| 18 | + | name: "short image fits", | |||
| 19 | + | image: "nginx:latest", | |||
| 20 | + | maxLen: 20, | |||
| 21 | + | expected: "nginx:latest", | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | name: "exact fit", | |||
| 25 | + | image: "nginx:latest", | |||
| 26 | + | maxLen: 12, | |||
| 27 | + | expected: "nginx:latest", | |||
| 28 | + | }, | |||
| 29 | + | ||||
| 30 | + | // Regular tag truncation | |||
| 31 | + | { | |||
| 32 | + | name: "truncate long tag with ellipsis", | |||
| 33 | + | image: "registry.test.com/test:v1234567890", | |||
| 34 | + | maxLen: 30, | |||
| 35 | + | expected: "registry.test.com/test:v123...", | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "truncate very long image", | |||
| 39 | + | image: "registry.example.com/organization/project/image:v1.2.3-beta.4", | |||
| 40 | + | maxLen: 35, | |||
| 41 | + | expected: "registry.example.com/organizatio...", | |||
| 42 | + | }, | |||
| 43 | + | ||||
| 44 | + | // Digest truncation | |||
| 45 | + | { | |||
| 46 | + | name: "digest shortened to 12 chars", | |||
| 47 | + | image: "myapp@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", | |||
| 48 | + | maxLen: 50, | |||
| 49 | + | expected: "myapp@sha256:a3ed95caeb02", | |||
| 50 | + | }, | |||
| 51 | + | { | |||
| 52 | + | name: "digest truncated with ellipsis when too long", | |||
| 53 | + | image: "registry.example.com/org/app@sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", | |||
| 54 | + | maxLen: 35, | |||
| 55 | + | expected: "registry.example.com/org/app@sha...", | |||
| 56 | + | }, | |||
| 57 | + | { | |||
| 58 | + | name: "short digest fits", | |||
| 59 | + | image: "app@sha256:abc123", | |||
| 60 | + | maxLen: 30, | |||
| 61 | + | expected: "app@sha256:abc123", | |||
| 62 | + | }, | |||
| 63 | + | ||||
| 64 | + | // Edge cases | |||
| 65 | + | { | |||
| 66 | + | name: "maxLen zero returns empty", | |||
| 67 | + | image: "nginx:latest", | |||
| 68 | + | maxLen: 0, | |||
| 69 | + | expected: "", | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | name: "maxLen negative returns empty", | |||
| 73 | + | image: "nginx:latest", | |||
| 74 | + | maxLen: -1, | |||
| 75 | + | expected: "", | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "maxLen 3 or less - no ellipsis", | |||
| 79 | + | image: "nginx:latest", | |||
| 80 | + | maxLen: 3, | |||
| 81 | + | expected: "ngi", | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "maxLen 4 - truncate with ellipsis", | |||
| 85 | + | image: "nginx:latest", | |||
| 86 | + | maxLen: 4, | |||
| 87 | + | expected: "n...", | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "empty image", | |||
| 91 | + | image: "", | |||
| 92 | + | maxLen: 10, | |||
| 93 | + | expected: "", | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | for _, tt := range tests { | |||
| 98 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 99 | + | result := truncateImage(tt.image, tt.maxLen) | |||
| 100 | + | assert.Equal(t, tt.expected, result) | |||
| 101 | + | }) | |||
| 102 | + | } | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestTruncateNetwork(t *testing.T) { | |||
| 106 | + | tests := []struct { | |||
| 107 | + | name string | |||
| 108 | + | network string | |||
| 109 | + | maxLen int | |||
| 110 | + | expected string | |||
| 111 | + | }{ | |||
| 112 | + | // Basic cases | |||
| 113 | + | { | |||
| 114 | + | name: "short network fits", | |||
| 115 | + | network: "gordon-net", | |||
| 116 | + | maxLen: 20, | |||
| 117 | + | expected: "gordon-net", | |||
| 118 | + | }, | |||
| 119 | + | { | |||
| 120 | + | name: "exact fit", | |||
| 121 | + | network: "gordon-net", | |||
| 122 | + | maxLen: 10, | |||
| 123 | + | expected: "gordon-net", | |||
| 124 | + | }, | |||
| 125 | + | ||||
| 126 | + | // Truncation | |||
| 127 | + | { | |||
| 128 | + | name: "truncate long network with ellipsis", | |||
| 129 | + | network: "gordon-my-very-long-network-name", | |||
| 130 | + | maxLen: 20, | |||
| 131 | + | expected: "gordon-my-very-lo...", | |||
| 132 | + | }, | |||
| 133 | + | ||||
| 134 | + | // Special values | |||
| 135 | + | { | |||
| 136 | + | name: "empty returns dash", | |||
| 137 | + | network: "", | |||
| 138 | + | maxLen: 10, | |||
| 139 | + | expected: "-", | |||
| 140 | + | }, | |||
| 141 | + | { | |||
| 142 | + | name: "dash returns dash", | |||
| 143 | + | network: "-", | |||
| 144 | + | maxLen: 10, | |||
| 145 | + | expected: "-", | |||
| 146 | + | }, | |||
| 147 | + | ||||
| 148 | + | // Edge cases | |||
| 149 | + | { | |||
| 150 | + | name: "maxLen zero returns empty", | |||
| 151 | + | network: "gordon-net", | |||
| 152 | + | maxLen: 0, | |||
| 153 | + | expected: "", | |||
| 154 | + | }, | |||
| 155 | + | { | |||
| 156 | + | name: "maxLen negative returns empty", | |||
| 157 | + | network: "gordon-net", | |||
| 158 | + | maxLen: -1, | |||
| 159 | + | expected: "", | |||
| 160 | + | }, | |||
| 161 | + | { | |||
| 162 | + | name: "maxLen 3 or less - no ellipsis", | |||
| 163 | + | network: "gordon-net", | |||
| 164 | + | maxLen: 3, | |||
| 165 | + | expected: "gor", | |||
| 166 | + | }, | |||
| 167 | + | { | |||
| 168 | + | name: "maxLen 4 - truncate with ellipsis", | |||
| 169 | + | network: "gordon-net", | |||
| 170 | + | maxLen: 4, | |||
| 171 | + | expected: "g...", | |||
| 172 | + | }, | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | for _, tt := range tests { | |||
| 176 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 177 | + | result := truncateNetwork(tt.network, tt.maxLen) | |||
| 178 | + | assert.Equal(t, tt.expected, result) | |||
| 179 | + | }) | |||
| 180 | + | } | |||
| 181 | + | } | |||
M internal/adapters/in/http/registry/handler_test.go +142 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "net/http" | |
| 7 | 7 | "net/http/httptest" | |
| 8 | + | "os" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/bnema/zerowrap" | |
| @@ -33,6 +34,19 @@ func TestHandler_Base(t *testing.T) { | |||
| 33 | 34 | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | |
| 34 | 35 | } | |
| 35 | 36 | ||
| 37 | + | func TestHandler_NotFound(t *testing.T) { | |
| 38 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 39 | + | ||
| 40 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 41 | + | ||
| 42 | + | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | |
| 43 | + | rec := httptest.NewRecorder() | |
| 44 | + | ||
| 45 | + | handler.ServeHTTP(rec, req) | |
| 46 | + | ||
| 47 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 48 | + | } | |
| 49 | + | ||
| 36 | 50 | func TestHandler_GetManifest_Success(t *testing.T) { | |
| 37 | 51 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 38 | 52 | ||
| @@ -96,6 +110,134 @@ func TestHandler_GetManifest_NotFound(t *testing.T) { | |||
| 96 | 110 | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | |
| 97 | 111 | } | |
| 98 | 112 | ||
| 113 | + | func TestHandler_GetManifest_NestedName(t *testing.T) { | |
| 114 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 115 | + | ||
| 116 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 117 | + | ||
| 118 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 119 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | |
| 120 | + | Name: "org/project/app", | |
| 121 | + | Reference: "v1.0", | |
| 122 | + | ContentType: "application/vnd.docker.distribution.manifest.v2+json", | |
| 123 | + | Data: manifestData, | |
| 124 | + | }, nil) | |
| 125 | + | ||
| 126 | + | req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil) | |
| 127 | + | rec := httptest.NewRecorder() | |
| 128 | + | ||
| 129 | + | handler.ServeHTTP(rec, req) | |
| 130 | + | ||
| 131 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestHandler_PutManifest_Success(t *testing.T) { | |
| 135 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 136 | + | ||
| 137 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 138 | + | ||
| 139 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 140 | + | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | |
| 141 | + | return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json" | |
| 142 | + | })).Return("sha256:abc123", nil) | |
| 143 | + | ||
| 144 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |
| 145 | + | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | |
| 146 | + | rec := httptest.NewRecorder() | |
| 147 | + | ||
| 148 | + | handler.ServeHTTP(rec, req) | |
| 149 | + | ||
| 150 | + | assert.Equal(t, http.StatusCreated, rec.Code) | |
| 151 | + | assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest")) | |
| 152 | + | assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest") | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |
| 156 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 157 | + | ||
| 158 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 159 | + | ||
| 160 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 161 | + | ||
| 162 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |
| 163 | + | // No Content-Type header | |
| 164 | + | rec := httptest.NewRecorder() | |
| 165 | + | ||
| 166 | + | handler.ServeHTTP(rec, req) | |
| 167 | + | ||
| 168 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestHandler_PutManifest_StorageError(t *testing.T) { | |
| 172 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 173 | + | ||
| 174 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 175 | + | ||
| 176 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |
| 177 | + | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) | |
| 178 | + | ||
| 179 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |
| 180 | + | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | |
| 181 | + | rec := httptest.NewRecorder() | |
| 182 | + | ||
| 183 | + | handler.ServeHTTP(rec, req) | |
| 184 | + | ||
| 185 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |
| 189 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 190 | + | ||
| 191 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 192 | + | ||
| 193 | + | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | |
| 194 | + | rec := httptest.NewRecorder() | |
| 195 | + | ||
| 196 | + | handler.ServeHTTP(rec, req) | |
| 197 | + | ||
| 198 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestHandler_GetBlob_Success(t *testing.T) { | |
| 202 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 203 | + | ||
| 204 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 205 | + | ||
| 206 | + | // Create a temp file for http.ServeFile | |
| 207 | + | tmpFile, err := os.CreateTemp("", "blob-*") | |
| 208 | + | assert.NoError(t, err) | |
| 209 | + | defer tmpFile.Close() | |
| 210 | + | ||
| 211 | + | blobContent := []byte("blob content here") | |
| 212 | + | _, err = tmpFile.Write(blobContent) | |
| 213 | + | assert.NoError(t, err) | |
| 214 | + | ||
| 215 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |
| 216 | + | ||
| 217 | + | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |
| 218 | + | rec := httptest.NewRecorder() | |
| 219 | + | ||
| 220 | + | handler.ServeHTTP(rec, req) | |
| 221 | + | ||
| 222 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 223 | + | assert.Equal(t, blobContent, rec.Body.Bytes()) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func TestHandler_GetBlob_NotFound(t *testing.T) { | |
| 227 | + | registrySvc := inmocks.NewMockRegistryService(t) | |
| 228 | + | ||
| 229 | + | handler := NewHandler(registrySvc, testLogger()) | |
| 230 | + | ||
| 231 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |
| 232 | + | ||
| 233 | + | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |
| 234 | + | rec := httptest.NewRecorder() | |
| 235 | + | ||
| 236 | + | handler.ServeHTTP(rec, req) | |
| 237 | + | ||
| 238 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 239 | + | } | |
| 240 | + | ||
| 99 | 241 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | |
| 100 | 242 | registrySvc := inmocks.NewMockRegistryService(t) | |
| 101 | 243 | ||
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 8 | + | "os" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | 10 | ||||
| 10 | "github.com/bnema/zerowrap" | 11 | "github.com/bnema/zerowrap" | ||
| @@ -33,6 +34,19 @@ func TestHandler_Base(t *testing.T) { | |||||
| 33 | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | 34 | assert.Equal(t, "registry/2.0", rec.Header().Get("Docker-Distribution-API-Version")) | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 37 | + | func TestHandler_NotFound(t *testing.T) { | |||
| 38 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 39 | + | ||||
| 40 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 41 | + | ||||
| 42 | + | req := httptest.NewRequest("GET", "/v2/unknown/path", nil) | |||
| 43 | + | rec := httptest.NewRecorder() | |||
| 44 | + | ||||
| 45 | + | handler.ServeHTTP(rec, req) | |||
| 46 | + | ||||
| 47 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 36 | func TestHandler_GetManifest_Success(t *testing.T) { | 50 | func TestHandler_GetManifest_Success(t *testing.T) { | ||
| 37 | registrySvc := inmocks.NewMockRegistryService(t) | 51 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 38 | 52 | ||||
| @@ -96,6 +110,134 @@ func TestHandler_GetManifest_NotFound(t *testing.T) { | |||||
| 96 | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | 110 | assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) | ||
| 97 | } | 111 | } | ||
| 98 | 112 | ||||
| 113 | + | func TestHandler_GetManifest_NestedName(t *testing.T) { | |||
| 114 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 115 | + | ||||
| 116 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 117 | + | ||||
| 118 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |||
| 119 | + | registrySvc.EXPECT().GetManifest(mock.Anything, "org/project/app", "v1.0").Return(&domain.Manifest{ | |||
| 120 | + | Name: "org/project/app", | |||
| 121 | + | Reference: "v1.0", | |||
| 122 | + | ContentType: "application/vnd.docker.distribution.manifest.v2+json", | |||
| 123 | + | Data: manifestData, | |||
| 124 | + | }, nil) | |||
| 125 | + | ||||
| 126 | + | req := httptest.NewRequest("GET", "/v2/org/project/app/manifests/v1.0", nil) | |||
| 127 | + | rec := httptest.NewRecorder() | |||
| 128 | + | ||||
| 129 | + | handler.ServeHTTP(rec, req) | |||
| 130 | + | ||||
| 131 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func TestHandler_PutManifest_Success(t *testing.T) { | |||
| 135 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 136 | + | ||||
| 137 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 138 | + | ||||
| 139 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |||
| 140 | + | registrySvc.EXPECT().PutManifest(mock.Anything, mock.MatchedBy(func(m *domain.Manifest) bool { | |||
| 141 | + | return m.Name == "myapp" && m.Reference == "latest" && m.ContentType == "application/vnd.docker.distribution.manifest.v2+json" | |||
| 142 | + | })).Return("sha256:abc123", nil) | |||
| 143 | + | ||||
| 144 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |||
| 145 | + | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | |||
| 146 | + | rec := httptest.NewRecorder() | |||
| 147 | + | ||||
| 148 | + | handler.ServeHTTP(rec, req) | |||
| 149 | + | ||||
| 150 | + | assert.Equal(t, http.StatusCreated, rec.Code) | |||
| 151 | + | assert.Equal(t, "sha256:abc123", rec.Header().Get("Docker-Content-Digest")) | |||
| 152 | + | assert.Contains(t, rec.Header().Get("Location"), "/v2/myapp/manifests/latest") | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestHandler_PutManifest_MissingContentType(t *testing.T) { | |||
| 156 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 157 | + | ||||
| 158 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 159 | + | ||||
| 160 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |||
| 161 | + | ||||
| 162 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |||
| 163 | + | // No Content-Type header | |||
| 164 | + | rec := httptest.NewRecorder() | |||
| 165 | + | ||||
| 166 | + | handler.ServeHTTP(rec, req) | |||
| 167 | + | ||||
| 168 | + | assert.Equal(t, http.StatusBadRequest, rec.Code) | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestHandler_PutManifest_StorageError(t *testing.T) { | |||
| 172 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 173 | + | ||||
| 174 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 175 | + | ||||
| 176 | + | manifestData := []byte(`{"schemaVersion": 2}`) | |||
| 177 | + | registrySvc.EXPECT().PutManifest(mock.Anything, mock.Anything).Return("", assert.AnError) | |||
| 178 | + | ||||
| 179 | + | req := httptest.NewRequest("PUT", "/v2/myapp/manifests/latest", bytes.NewReader(manifestData)) | |||
| 180 | + | req.Header.Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | |||
| 181 | + | rec := httptest.NewRecorder() | |||
| 182 | + | ||||
| 183 | + | handler.ServeHTTP(rec, req) | |||
| 184 | + | ||||
| 185 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestHandler_ManifestRoutes_MethodNotAllowed(t *testing.T) { | |||
| 189 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 190 | + | ||||
| 191 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 192 | + | ||||
| 193 | + | req := httptest.NewRequest("DELETE", "/v2/myapp/manifests/latest", nil) | |||
| 194 | + | rec := httptest.NewRecorder() | |||
| 195 | + | ||||
| 196 | + | handler.ServeHTTP(rec, req) | |||
| 197 | + | ||||
| 198 | + | assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestHandler_GetBlob_Success(t *testing.T) { | |||
| 202 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 203 | + | ||||
| 204 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 205 | + | ||||
| 206 | + | // Create a temp file for http.ServeFile | |||
| 207 | + | tmpFile, err := os.CreateTemp("", "blob-*") | |||
| 208 | + | assert.NoError(t, err) | |||
| 209 | + | defer tmpFile.Close() | |||
| 210 | + | ||||
| 211 | + | blobContent := []byte("blob content here") | |||
| 212 | + | _, err = tmpFile.Write(blobContent) | |||
| 213 | + | assert.NoError(t, err) | |||
| 214 | + | ||||
| 215 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(tmpFile.Name(), nil) | |||
| 216 | + | ||||
| 217 | + | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", nil) | |||
| 218 | + | rec := httptest.NewRecorder() | |||
| 219 | + | ||||
| 220 | + | handler.ServeHTTP(rec, req) | |||
| 221 | + | ||||
| 222 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 223 | + | assert.Equal(t, blobContent, rec.Body.Bytes()) | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func TestHandler_GetBlob_NotFound(t *testing.T) { | |||
| 227 | + | registrySvc := inmocks.NewMockRegistryService(t) | |||
| 228 | + | ||||
| 229 | + | handler := NewHandler(registrySvc, testLogger()) | |||
| 230 | + | ||||
| 231 | + | registrySvc.EXPECT().GetBlobPath(mock.Anything, "sha256:0000000000000000000000000000000000000000000000000000000000000000").Return("", assert.AnError) | |||
| 232 | + | ||||
| 233 | + | req := httptest.NewRequest("GET", "/v2/myapp/blobs/sha256:0000000000000000000000000000000000000000000000000000000000000000", nil) | |||
| 234 | + | rec := httptest.NewRecorder() | |||
| 235 | + | ||||
| 236 | + | handler.ServeHTTP(rec, req) | |||
| 237 | + | ||||
| 238 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 99 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | 241 | func TestHandler_BlobRoutes_MethodNotAllowed(t *testing.T) { | ||
| 100 | registrySvc := inmocks.NewMockRegistryService(t) | 242 | registrySvc := inmocks.NewMockRegistryService(t) | ||
| 101 | 243 | ||||
M internal/usecase/container/routes_test.go +3 -0
| @@ -79,6 +79,9 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) { | |||
| 79 | 79 | svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}) | |
| 80 | 80 | ctx := testContext() | |
| 81 | 81 | ||
| 82 | + | // getAllAttachments is called even with empty containers | |
| 83 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 84 | + | ||
| 82 | 85 | results := svc.ListRoutesWithDetails(ctx) | |
| 83 | 86 | ||
| 84 | 87 | assert.Empty(t, results) | |
| @@ -79,6 +79,9 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) { | |||||
| 79 | svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}) | 79 | svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}) | ||
| 80 | ctx := testContext() | 80 | ctx := testContext() | ||
| 81 | 81 | ||||
| 82 | + | // getAllAttachments is called even with empty containers | |||
| 83 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 84 | + | ||||
| 82 | results := svc.ListRoutesWithDetails(ctx) | 85 | results := svc.ListRoutesWithDetails(ctx) | ||
| 83 | 86 | ||||
| 84 | assert.Empty(t, results) | 87 | assert.Empty(t, results) | ||
M internal/usecase/container/service.go +47 -1
| @@ -326,6 +326,9 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||
| 326 | 326 | maps.Copy(containers, s.containers) | |
| 327 | 327 | s.mu.RUnlock() | |
| 328 | 328 | ||
| 329 | + | // Fetch all attachments once to avoid N+1 queries | |
| 330 | + | attachmentsByDomain := s.getAllAttachments(ctx) | |
| 331 | + | ||
| 329 | 332 | results := make([]domain.RouteInfo, 0, len(containers)) | |
| 330 | 333 | for domainName, container := range containers { | |
| 331 | 334 | network := "" | |
| @@ -352,7 +355,7 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||
| 352 | 355 | ContainerID: containerID, | |
| 353 | 356 | ContainerStatus: status, | |
| 354 | 357 | Network: network, | |
| 355 | - | Attachments: s.getAttachmentsForDomain(ctx, domainName), | |
| 358 | + | Attachments: attachmentsByDomain[domainName], | |
| 356 | 359 | }) | |
| 357 | 360 | } | |
| 358 | 361 | ||
| @@ -940,6 +943,49 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string | |||
| 940 | 943 | return nil | |
| 941 | 944 | } | |
| 942 | 945 | ||
| 946 | + | return s.filterAttachments(containers, domainName) | |
| 947 | + | } | |
| 948 | + | ||
| 949 | + | // getAllAttachments fetches all containers once and returns attachments grouped by domain. | |
| 950 | + | // This avoids N+1 queries when listing multiple routes. | |
| 951 | + | func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment { | |
| 952 | + | containers, err := s.runtime.ListContainers(ctx, true) | |
| 953 | + | if err != nil { | |
| 954 | + | return nil | |
| 955 | + | } | |
| 956 | + | ||
| 957 | + | result := make(map[string][]domain.Attachment) | |
| 958 | + | for _, container := range containers { | |
| 959 | + | if container.Labels == nil { | |
| 960 | + | continue | |
| 961 | + | } | |
| 962 | + | if container.Labels[domain.LabelAttachment] != "true" { | |
| 963 | + | continue | |
| 964 | + | } | |
| 965 | + | ownerDomain := container.Labels[domain.LabelAttachedTo] | |
| 966 | + | if ownerDomain == "" { | |
| 967 | + | continue | |
| 968 | + | } | |
| 969 | + | image := container.Image | |
| 970 | + | serviceName := container.Name | |
| 971 | + | if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" { | |
| 972 | + | image = labelImage | |
| 973 | + | serviceName = extractServiceName(labelImage) | |
| 974 | + | } | |
| 975 | + | attachment := domain.Attachment{ | |
| 976 | + | Name: serviceName, | |
| 977 | + | Image: image, | |
| 978 | + | ContainerID: container.ID, | |
| 979 | + | Status: container.Status, | |
| 980 | + | } | |
| 981 | + | result[ownerDomain] = append(result[ownerDomain], attachment) | |
| 982 | + | } | |
| 983 | + | ||
| 984 | + | return result | |
| 985 | + | } | |
| 986 | + | ||
| 987 | + | // filterAttachments extracts attachments for a specific domain from a container list. | |
| 988 | + | func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment { | |
| 943 | 989 | attachments := make([]domain.Attachment, 0) | |
| 944 | 990 | for _, container := range containers { | |
| 945 | 991 | if container.Labels == nil { | |
| @@ -326,6 +326,9 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||||
| 326 | maps.Copy(containers, s.containers) | 326 | maps.Copy(containers, s.containers) | ||
| 327 | s.mu.RUnlock() | 327 | s.mu.RUnlock() | ||
| 328 | 328 | ||||
| 329 | + | // Fetch all attachments once to avoid N+1 queries | |||
| 330 | + | attachmentsByDomain := s.getAllAttachments(ctx) | |||
| 331 | + | ||||
| 329 | results := make([]domain.RouteInfo, 0, len(containers)) | 332 | results := make([]domain.RouteInfo, 0, len(containers)) | ||
| 330 | for domainName, container := range containers { | 333 | for domainName, container := range containers { | ||
| 331 | network := "" | 334 | network := "" | ||
| @@ -352,7 +355,7 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||||
| 352 | ContainerID: containerID, | 355 | ContainerID: containerID, | ||
| 353 | ContainerStatus: status, | 356 | ContainerStatus: status, | ||
| 354 | Network: network, | 357 | Network: network, | ||
| 355 | - | Attachments: s.getAttachmentsForDomain(ctx, domainName), | 358 | + | Attachments: attachmentsByDomain[domainName], |
| 356 | }) | 359 | }) | ||
| 357 | } | 360 | } | ||
| 358 | 361 | ||||
| @@ -940,6 +943,49 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string | |||||
| 940 | return nil | 943 | return nil | ||
| 941 | } | 944 | } | ||
| 942 | 945 | ||||
| 946 | + | return s.filterAttachments(containers, domainName) | |||
| 947 | + | } | |||
| 948 | + | ||||
| 949 | + | // getAllAttachments fetches all containers once and returns attachments grouped by domain. | |||
| 950 | + | // This avoids N+1 queries when listing multiple routes. | |||
| 951 | + | func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment { | |||
| 952 | + | containers, err := s.runtime.ListContainers(ctx, true) | |||
| 953 | + | if err != nil { | |||
| 954 | + | return nil | |||
| 955 | + | } | |||
| 956 | + | ||||
| 957 | + | result := make(map[string][]domain.Attachment) | |||
| 958 | + | for _, container := range containers { | |||
| 959 | + | if container.Labels == nil { | |||
| 960 | + | continue | |||
| 961 | + | } | |||
| 962 | + | if container.Labels[domain.LabelAttachment] != "true" { | |||
| 963 | + | continue | |||
| 964 | + | } | |||
| 965 | + | ownerDomain := container.Labels[domain.LabelAttachedTo] | |||
| 966 | + | if ownerDomain == "" { | |||
| 967 | + | continue | |||
| 968 | + | } | |||
| 969 | + | image := container.Image | |||
| 970 | + | serviceName := container.Name | |||
| 971 | + | if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" { | |||
| 972 | + | image = labelImage | |||
| 973 | + | serviceName = extractServiceName(labelImage) | |||
| 974 | + | } | |||
| 975 | + | attachment := domain.Attachment{ | |||
| 976 | + | Name: serviceName, | |||
| 977 | + | Image: image, | |||
| 978 | + | ContainerID: container.ID, | |||
| 979 | + | Status: container.Status, | |||
| 980 | + | } | |||
| 981 | + | result[ownerDomain] = append(result[ownerDomain], attachment) | |||
| 982 | + | } | |||
| 983 | + | ||||
| 984 | + | return result | |||
| 985 | + | } | |||
| 986 | + | ||||
| 987 | + | // filterAttachments extracts attachments for a specific domain from a container list. | |||
| 988 | + | func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment { | |||
| 943 | attachments := make([]domain.Attachment, 0) | 989 | attachments := make([]domain.Attachment, 0) | ||
| 944 | for _, container := range containers { | 990 | for _, container := range containers { | ||
| 945 | if container.Labels == nil { | 991 | if container.Labels == nil { | ||