detached 339400c

Merge pull request #39 from bnema/fix/validate-image-exists-on-route-add

feat(admin): validate image exists in registry before adding route
commit
339400c9d219663cf289de5bdfbfdcc868dd97f0
author
bnema · · 2026-01-28 20:59 UTC
committer
GitHub · · 2026-01-28 20:59 UTC
merge of
43f3ec2 + 5d5d9c2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +248 -21
M internal/adapters/in/http/admin/handler.go +34 -0
@@ -15,6 +15,7 @@ import (
1515 "github.com/bnema/gordon/internal/boundaries/in"
1616 "github.com/bnema/gordon/internal/boundaries/out"
1717 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/validation"
1819 )
1920
2021 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.
@@ -31,6 +32,7 @@ type Handler struct {
3132 healthSvc in.HealthService
3233 secretSvc in.SecretService
3334 logSvc in.LogService
35+ registrySvc in.RegistryService
3436 eventBus out.EventPublisher
3537 log zerowrap.Logger
3638 }
@@ -84,6 +86,7 @@ func NewHandler(
8486 healthSvc in.HealthService,
8587 secretSvc in.SecretService,
8688 logSvc in.LogService,
89+ registrySvc in.RegistryService,
8790 eventBus out.EventPublisher,
8891 log zerowrap.Logger,
8992 ) *Handler {
@@ -94,6 +97,7 @@ func NewHandler(
9497 healthSvc: healthSvc,
9598 secretSvc: secretSvc,
9699 logSvc: logSvc,
100+ registrySvc: registrySvc,
97101 eventBus: eventBus,
98102 log: log,
99103 }
@@ -274,6 +278,21 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
274278 return
275279 }
276280
281+ // Validate image exists in registry before adding route
282+ if h.registrySvc != nil {
283+ imageName, imageRef := validation.ParseImageReference(route.Image)
284+ if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
285+ if errors.Is(err, domain.ErrManifestNotFound) {
286+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
287+ h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
288+ } else {
289+ log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
290+ h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
291+ }
292+ return
293+ }
294+ }
295+
277296 if err := h.configSvc.AddRoute(ctx, route); err != nil {
278297 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
279298 switch {
@@ -316,6 +335,21 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
316335
317336 route.Domain = routeDomain
318337
338+ // Validate image exists in registry before updating route
339+ if h.registrySvc != nil && route.Image != "" {
340+ imageName, imageRef := validation.ParseImageReference(route.Image)
341+ if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
342+ if errors.Is(err, domain.ErrManifestNotFound) {
343+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
344+ h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
345+ } else {
346+ log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
347+ h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
348+ }
349+ return
350+ }
351+ }
352+
319353 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
320354 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
321355 switch {
@@ -15,6 +15,7 @@ import (
15 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
16 "github.com/bnema/gordon/internal/boundaries/out"16 "github.com/bnema/gordon/internal/boundaries/out"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18+ "github.com/bnema/gordon/pkg/validation"
18 )19 )
19 20
20 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.21 // maxAdminRequestSize is the maximum allowed size for admin API request bodies.
@@ -31,6 +32,7 @@ type Handler struct {
31 healthSvc in.HealthService32 healthSvc in.HealthService
32 secretSvc in.SecretService33 secretSvc in.SecretService
33 logSvc in.LogService34 logSvc in.LogService
35+ registrySvc in.RegistryService
34 eventBus out.EventPublisher36 eventBus out.EventPublisher
35 log zerowrap.Logger37 log zerowrap.Logger
36 }38 }
@@ -84,6 +86,7 @@ func NewHandler(
84 healthSvc in.HealthService,86 healthSvc in.HealthService,
85 secretSvc in.SecretService,87 secretSvc in.SecretService,
86 logSvc in.LogService,88 logSvc in.LogService,
89+ registrySvc in.RegistryService,
87 eventBus out.EventPublisher,90 eventBus out.EventPublisher,
88 log zerowrap.Logger,91 log zerowrap.Logger,
89 ) *Handler {92 ) *Handler {
@@ -94,6 +97,7 @@ func NewHandler(
94 healthSvc: healthSvc,97 healthSvc: healthSvc,
95 secretSvc: secretSvc,98 secretSvc: secretSvc,
96 logSvc: logSvc,99 logSvc: logSvc,
100+ registrySvc: registrySvc,
97 eventBus: eventBus,101 eventBus: eventBus,
98 log: log,102 log: log,
99 }103 }
@@ -274,6 +278,21 @@ func (h *Handler) handleRoutesPost(w http.ResponseWriter, r *http.Request) {
274 return278 return
275 }279 }
276 280
281+ // Validate image exists in registry before adding route
282+ if h.registrySvc != nil {
283+ imageName, imageRef := validation.ParseImageReference(route.Image)
284+ if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
285+ if errors.Is(err, domain.ErrManifestNotFound) {
286+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
287+ h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
288+ } else {
289+ log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
290+ h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
291+ }
292+ return
293+ }
294+ }
295+
277 if err := h.configSvc.AddRoute(ctx, route); err != nil {296 if err := h.configSvc.AddRoute(ctx, route); err != nil {
278 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")297 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
279 switch {298 switch {
@@ -316,6 +335,21 @@ func (h *Handler) handleRoutesPut(w http.ResponseWriter, r *http.Request, routeD
316 335
317 route.Domain = routeDomain336 route.Domain = routeDomain
318 337
338+ // Validate image exists in registry before updating route
339+ if h.registrySvc != nil && route.Image != "" {
340+ imageName, imageRef := validation.ParseImageReference(route.Image)
341+ if _, err := h.registrySvc.GetManifest(ctx, imageName, imageRef); err != nil {
342+ if errors.Is(err, domain.ErrManifestNotFound) {
343+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
344+ h.sendError(w, http.StatusBadRequest, fmt.Sprintf("image '%s' not found in Gordon's registry. Please push it first using: gordon push %s", route.Image, route.Image))
345+ } else {
346+ log.Error().Err(err).Str("image", route.Image).Msg("failed to check image in registry")
347+ h.sendError(w, http.StatusServiceUnavailable, "failed to verify image in registry")
348+ }
349+ return
350+ }
351+ }
352+
319 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {353 if err := h.configSvc.UpdateRoute(ctx, route); err != nil {
320 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")354 log.Error().Err(err).Str("domain", routeDomain).Msg("failed to update route")
321 switch {355 switch {
M internal/adapters/in/http/admin/handler_test.go +82 -20
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "fmt"
78 "net/http"
89 "net/http/httptest"
910 "testing"
@@ -33,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3334 containerSvc := inmocks.NewMockContainerService(t)
3435 secretSvc := inmocks.NewMockSecretService(t)
3536
36- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
3738
3839 tests := []struct {
3940 name string
@@ -94,8 +95,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9495 authSvc := inmocks.NewMockAuthService(t)
9596 containerSvc := inmocks.NewMockContainerService(t)
9697 secretSvc := inmocks.NewMockSecretService(t)
98+ registrySvc := inmocks.NewMockRegistryService(t)
9799
98- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
100+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
99101
100102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101103
@@ -135,6 +137,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
135137 t.Run(tt.name, func(t *testing.T) {
136138 if tt.wantStatus == http.StatusCreated {
137139 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
140+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
138141 }
139142
140143 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
@@ -153,8 +156,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
153156 authSvc := inmocks.NewMockAuthService(t)
154157 containerSvc := inmocks.NewMockContainerService(t)
155158 secretSvc := inmocks.NewMockSecretService(t)
159+ registrySvc := inmocks.NewMockRegistryService(t)
156160
157- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
161+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
158162
159163 routeJSON := `{"image": "myapp:v2"}`
160164
@@ -179,6 +183,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
179183 t.Run(tt.name, func(t *testing.T) {
180184 if tt.wantStatus == http.StatusOK {
181185 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
186+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
182187 }
183188
184189 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
@@ -198,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198203 containerSvc := inmocks.NewMockContainerService(t)
199204 secretSvc := inmocks.NewMockSecretService(t)
200205
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
206+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
202207
203208 tests := []struct {
204209 name string
@@ -242,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242247 containerSvc := inmocks.NewMockContainerService(t)
243248 secretSvc := inmocks.NewMockSecretService(t)
244249
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
250+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
246251
247252 tests := []struct {
248253 name string
@@ -299,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299304 containerSvc := inmocks.NewMockContainerService(t)
300305 secretSvc := inmocks.NewMockSecretService(t)
301306
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
307+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
303308
304309 secretsJSON := `{"API_KEY": "secret123"}`
305310
@@ -348,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348353 containerSvc := inmocks.NewMockContainerService(t)
349354 secretSvc := inmocks.NewMockSecretService(t)
350355
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
356+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
352357
353358 tests := []struct {
354359 name string
@@ -392,7 +397,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392397 containerSvc := inmocks.NewMockContainerService(t)
393398 secretSvc := inmocks.NewMockSecretService(t)
394399
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
396401
397402 tests := []struct {
398403 name string
@@ -446,7 +451,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446451 containerSvc := inmocks.NewMockContainerService(t)
447452 secretSvc := inmocks.NewMockSecretService(t)
448453
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
454+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
450455
451456 tests := []struct {
452457 name string
@@ -503,7 +508,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503508 containerSvc := inmocks.NewMockContainerService(t)
504509 secretSvc := inmocks.NewMockSecretService(t)
505510
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
511+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507512
508513 tests := []struct {
509514 name string
@@ -557,7 +562,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557562 containerSvc := inmocks.NewMockContainerService(t)
558563 secretSvc := inmocks.NewMockSecretService(t)
559564
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
565+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
561566
562567 expectedRoutes := []domain.Route{
563568 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -592,7 +597,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
592597 containerSvc := inmocks.NewMockContainerService(t)
593598 secretSvc := inmocks.NewMockSecretService(t)
594599
595- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
600+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
596601
597602 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
598603 Domain: "app.example.com", Image: "app:latest",
@@ -623,7 +628,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
623628 containerSvc := inmocks.NewMockContainerService(t)
624629 secretSvc := inmocks.NewMockSecretService(t)
625630
626- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
631+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
627632
628633 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
629634
@@ -665,10 +670,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
665670 authSvc := inmocks.NewMockAuthService(t)
666671 containerSvc := inmocks.NewMockContainerService(t)
667672 secretSvc := inmocks.NewMockSecretService(t)
673+ registrySvc := inmocks.NewMockRegistryService(t)
668674
669- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
675+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
670676
671677 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
678+ registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
672679
673680 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
674681 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -681,13 +688,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
681688 }
682689 }
683690
691+func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
692+ configSvc := inmocks.NewMockConfigService(t)
693+ authSvc := inmocks.NewMockAuthService(t)
694+ containerSvc := inmocks.NewMockContainerService(t)
695+ secretSvc := inmocks.NewMockSecretService(t)
696+ registrySvc := inmocks.NewMockRegistryService(t)
697+
698+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
699+
700+ tests := []struct {
701+ name string
702+ image string
703+ body string
704+ manifestName string
705+ manifestRef string
706+ }{
707+ {
708+ name: "tagged image not found",
709+ image: "myapp:latest",
710+ body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
711+ manifestName: "myapp",
712+ manifestRef: "latest",
713+ },
714+ {
715+ name: "digest image not found",
716+ image: "myapp@sha256:abc123",
717+ body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
718+ manifestName: "myapp",
719+ manifestRef: "sha256:abc123",
720+ },
721+ {
722+ name: "implicit latest tag not found",
723+ image: "myapp",
724+ body: `{"domain": "app.example.com", "image": "myapp"}`,
725+ manifestName: "myapp",
726+ manifestRef: "latest",
727+ },
728+ }
729+
730+ for _, tt := range tests {
731+ t.Run(tt.name, func(t *testing.T) {
732+ registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
733+
734+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
735+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
736+ rec := httptest.NewRecorder()
737+
738+ handler.ServeHTTP(rec, req)
739+
740+ assert.Equal(t, http.StatusBadRequest, rec.Code)
741+ assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
742+ })
743+ }
744+}
745+
684746 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
685747 configSvc := inmocks.NewMockConfigService(t)
686748 authSvc := inmocks.NewMockAuthService(t)
687749 containerSvc := inmocks.NewMockContainerService(t)
688750 secretSvc := inmocks.NewMockSecretService(t)
689751
690- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
752+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
691753
692754 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
693755 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -704,7 +766,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
704766 containerSvc := inmocks.NewMockContainerService(t)
705767 secretSvc := inmocks.NewMockSecretService(t)
706768
707- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
769+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
708770
709771 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
710772 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -721,7 +783,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
721783 containerSvc := inmocks.NewMockContainerService(t)
722784 secretSvc := inmocks.NewMockSecretService(t)
723785
724- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
786+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
725787
726788 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
727789 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -738,7 +800,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
738800 containerSvc := inmocks.NewMockContainerService(t)
739801 secretSvc := inmocks.NewMockSecretService(t)
740802
741- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
803+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
742804
743805 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
744806 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -755,7 +817,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
755817 containerSvc := inmocks.NewMockContainerService(t)
756818 secretSvc := inmocks.NewMockSecretService(t)
757819
758- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
820+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
759821
760822 tests := []struct {
761823 method string
@@ -788,7 +850,7 @@ func TestHandler_NotFound(t *testing.T) {
788850 containerSvc := inmocks.NewMockContainerService(t)
789851 secretSvc := inmocks.NewMockSecretService(t)
790852
791- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
853+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
792854
793855 req := httptest.NewRequest("GET", "/admin/unknown", nil)
794856 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "fmt"
7 "net/http"8 "net/http"
8 "net/http/httptest"9 "net/http/httptest"
9 "testing"10 "testing"
@@ -33,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
33 containerSvc := inmocks.NewMockContainerService(t)34 containerSvc := inmocks.NewMockContainerService(t)
34 secretSvc := inmocks.NewMockSecretService(t)35 secretSvc := inmocks.NewMockSecretService(t)
35 36
36- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
37 38
38 tests := []struct {39 tests := []struct {
39 name string40 name string
@@ -94,8 +95,9 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
94 authSvc := inmocks.NewMockAuthService(t)95 authSvc := inmocks.NewMockAuthService(t)
95 containerSvc := inmocks.NewMockContainerService(t)96 containerSvc := inmocks.NewMockContainerService(t)
96 secretSvc := inmocks.NewMockSecretService(t)97 secretSvc := inmocks.NewMockSecretService(t)
98+ registrySvc := inmocks.NewMockRegistryService(t)
97 99
98- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())100+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
99 101
100 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
101 103
@@ -135,6 +137,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
135 t.Run(tt.name, func(t *testing.T) {137 t.Run(tt.name, func(t *testing.T) {
136 if tt.wantStatus == http.StatusCreated {138 if tt.wantStatus == http.StatusCreated {
137 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()139 configSvc.EXPECT().AddRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
140+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "latest").Return(nil, nil).Maybe()
138 }141 }
139 142
140 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))143 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(routeJSON))
@@ -153,8 +156,9 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
153 authSvc := inmocks.NewMockAuthService(t)156 authSvc := inmocks.NewMockAuthService(t)
154 containerSvc := inmocks.NewMockContainerService(t)157 containerSvc := inmocks.NewMockContainerService(t)
155 secretSvc := inmocks.NewMockSecretService(t)158 secretSvc := inmocks.NewMockSecretService(t)
159+ registrySvc := inmocks.NewMockRegistryService(t)
156 160
157- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())161+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
158 162
159 routeJSON := `{"image": "myapp:v2"}`163 routeJSON := `{"image": "myapp:v2"}`
160 164
@@ -179,6 +183,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
179 t.Run(tt.name, func(t *testing.T) {183 t.Run(tt.name, func(t *testing.T) {
180 if tt.wantStatus == http.StatusOK {184 if tt.wantStatus == http.StatusOK {
181 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()185 configSvc.EXPECT().UpdateRoute(mock.Anything, mock.AnythingOfType("domain.Route")).Return(nil).Maybe()
186+ registrySvc.EXPECT().GetManifest(mock.Anything, "myapp", "v2").Return(nil, nil).Maybe()
182 }187 }
183 188
184 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))189 req := httptest.NewRequest("PUT", "/admin/routes/app.example.com", bytes.NewBufferString(routeJSON))
@@ -198,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198 containerSvc := inmocks.NewMockContainerService(t)203 containerSvc := inmocks.NewMockContainerService(t)
199 secretSvc := inmocks.NewMockSecretService(t)204 secretSvc := inmocks.NewMockSecretService(t)
200 205
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())206+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
202 207
203 tests := []struct {208 tests := []struct {
204 name string209 name string
@@ -242,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242 containerSvc := inmocks.NewMockContainerService(t)247 containerSvc := inmocks.NewMockContainerService(t)
243 secretSvc := inmocks.NewMockSecretService(t)248 secretSvc := inmocks.NewMockSecretService(t)
244 249
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())250+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
246 251
247 tests := []struct {252 tests := []struct {
248 name string253 name string
@@ -299,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299 containerSvc := inmocks.NewMockContainerService(t)304 containerSvc := inmocks.NewMockContainerService(t)
300 secretSvc := inmocks.NewMockSecretService(t)305 secretSvc := inmocks.NewMockSecretService(t)
301 306
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())307+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
303 308
304 secretsJSON := `{"API_KEY": "secret123"}`309 secretsJSON := `{"API_KEY": "secret123"}`
305 310
@@ -348,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348 containerSvc := inmocks.NewMockContainerService(t)353 containerSvc := inmocks.NewMockContainerService(t)
349 secretSvc := inmocks.NewMockSecretService(t)354 secretSvc := inmocks.NewMockSecretService(t)
350 355
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())356+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
352 357
353 tests := []struct {358 tests := []struct {
354 name string359 name string
@@ -392,7 +397,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392 containerSvc := inmocks.NewMockContainerService(t)397 containerSvc := inmocks.NewMockContainerService(t)
393 secretSvc := inmocks.NewMockSecretService(t)398 secretSvc := inmocks.NewMockSecretService(t)
394 399
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
396 401
397 tests := []struct {402 tests := []struct {
398 name string403 name string
@@ -446,7 +451,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446 containerSvc := inmocks.NewMockContainerService(t)451 containerSvc := inmocks.NewMockContainerService(t)
447 secretSvc := inmocks.NewMockSecretService(t)452 secretSvc := inmocks.NewMockSecretService(t)
448 453
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())454+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
450 455
451 tests := []struct {456 tests := []struct {
452 name string457 name string
@@ -503,7 +508,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503 containerSvc := inmocks.NewMockContainerService(t)508 containerSvc := inmocks.NewMockContainerService(t)
504 secretSvc := inmocks.NewMockSecretService(t)509 secretSvc := inmocks.NewMockSecretService(t)
505 510
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())511+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507 512
508 tests := []struct {513 tests := []struct {
509 name string514 name string
@@ -557,7 +562,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557 containerSvc := inmocks.NewMockContainerService(t)562 containerSvc := inmocks.NewMockContainerService(t)
558 secretSvc := inmocks.NewMockSecretService(t)563 secretSvc := inmocks.NewMockSecretService(t)
559 564
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())565+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
561 566
562 expectedRoutes := []domain.Route{567 expectedRoutes := []domain.Route{
563 {Domain: "app1.example.com", Image: "app1:latest"},568 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -592,7 +597,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
592 containerSvc := inmocks.NewMockContainerService(t)597 containerSvc := inmocks.NewMockContainerService(t)
593 secretSvc := inmocks.NewMockSecretService(t)598 secretSvc := inmocks.NewMockSecretService(t)
594 599
595- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())600+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
596 601
597 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{602 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
598 Domain: "app.example.com", Image: "app:latest",603 Domain: "app.example.com", Image: "app:latest",
@@ -623,7 +628,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
623 containerSvc := inmocks.NewMockContainerService(t)628 containerSvc := inmocks.NewMockContainerService(t)
624 secretSvc := inmocks.NewMockSecretService(t)629 secretSvc := inmocks.NewMockSecretService(t)
625 630
626- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())631+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
627 632
628 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)633 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
629 634
@@ -665,10 +670,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
665 authSvc := inmocks.NewMockAuthService(t)670 authSvc := inmocks.NewMockAuthService(t)
666 containerSvc := inmocks.NewMockContainerService(t)671 containerSvc := inmocks.NewMockContainerService(t)
667 secretSvc := inmocks.NewMockSecretService(t)672 secretSvc := inmocks.NewMockSecretService(t)
673+ registrySvc := inmocks.NewMockRegistryService(t)
668 674
669- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())675+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
670 676
671 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)677 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
678+ registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
672 679
673 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))680 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
674 req = req.WithContext(ctxWithScopes("admin:routes:write"))681 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -681,13 +688,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
681 }688 }
682 }689 }
683 690
691+func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
692+ configSvc := inmocks.NewMockConfigService(t)
693+ authSvc := inmocks.NewMockAuthService(t)
694+ containerSvc := inmocks.NewMockContainerService(t)
695+ secretSvc := inmocks.NewMockSecretService(t)
696+ registrySvc := inmocks.NewMockRegistryService(t)
697+
698+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
699+
700+ tests := []struct {
701+ name string
702+ image string
703+ body string
704+ manifestName string
705+ manifestRef string
706+ }{
707+ {
708+ name: "tagged image not found",
709+ image: "myapp:latest",
710+ body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
711+ manifestName: "myapp",
712+ manifestRef: "latest",
713+ },
714+ {
715+ name: "digest image not found",
716+ image: "myapp@sha256:abc123",
717+ body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
718+ manifestName: "myapp",
719+ manifestRef: "sha256:abc123",
720+ },
721+ {
722+ name: "implicit latest tag not found",
723+ image: "myapp",
724+ body: `{"domain": "app.example.com", "image": "myapp"}`,
725+ manifestName: "myapp",
726+ manifestRef: "latest",
727+ },
728+ }
729+
730+ for _, tt := range tests {
731+ t.Run(tt.name, func(t *testing.T) {
732+ registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, domain.ErrManifestNotFound)
733+
734+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
735+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
736+ rec := httptest.NewRecorder()
737+
738+ handler.ServeHTTP(rec, req)
739+
740+ assert.Equal(t, http.StatusBadRequest, rec.Code)
741+ assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
742+ })
743+ }
744+}
745+
684 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {746 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
685 configSvc := inmocks.NewMockConfigService(t)747 configSvc := inmocks.NewMockConfigService(t)
686 authSvc := inmocks.NewMockAuthService(t)748 authSvc := inmocks.NewMockAuthService(t)
687 containerSvc := inmocks.NewMockContainerService(t)749 containerSvc := inmocks.NewMockContainerService(t)
688 secretSvc := inmocks.NewMockSecretService(t)750 secretSvc := inmocks.NewMockSecretService(t)
689 751
690- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())752+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
691 753
692 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))754 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
693 req = req.WithContext(ctxWithScopes("admin:routes:write"))755 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -704,7 +766,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
704 containerSvc := inmocks.NewMockContainerService(t)766 containerSvc := inmocks.NewMockContainerService(t)
705 secretSvc := inmocks.NewMockSecretService(t)767 secretSvc := inmocks.NewMockSecretService(t)
706 768
707- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())769+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
708 770
709 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)771 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
710 req = req.WithContext(ctxWithScopes("admin:routes:write"))772 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -721,7 +783,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
721 containerSvc := inmocks.NewMockContainerService(t)783 containerSvc := inmocks.NewMockContainerService(t)
722 secretSvc := inmocks.NewMockSecretService(t)784 secretSvc := inmocks.NewMockSecretService(t)
723 785
724- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())786+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
725 787
726 req := httptest.NewRequest("GET", "/admin/secrets/", nil)788 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
727 req = req.WithContext(ctxWithScopes("admin:secrets:read"))789 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -738,7 +800,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
738 containerSvc := inmocks.NewMockContainerService(t)800 containerSvc := inmocks.NewMockContainerService(t)
739 secretSvc := inmocks.NewMockSecretService(t)801 secretSvc := inmocks.NewMockSecretService(t)
740 802
741- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())803+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
742 804
743 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)805 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
744 req = req.WithContext(ctxWithScopes("admin:secrets:write"))806 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -755,7 +817,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
755 containerSvc := inmocks.NewMockContainerService(t)817 containerSvc := inmocks.NewMockContainerService(t)
756 secretSvc := inmocks.NewMockSecretService(t)818 secretSvc := inmocks.NewMockSecretService(t)
757 819
758- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())820+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
759 821
760 tests := []struct {822 tests := []struct {
761 method string823 method string
@@ -788,7 +850,7 @@ func TestHandler_NotFound(t *testing.T) {
788 containerSvc := inmocks.NewMockContainerService(t)850 containerSvc := inmocks.NewMockContainerService(t)
789 secretSvc := inmocks.NewMockSecretService(t)851 secretSvc := inmocks.NewMockSecretService(t)
790 852
791- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())853+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
792 854
793 req := httptest.NewRequest("GET", "/admin/unknown", nil)855 req := httptest.NewRequest("GET", "/admin/unknown", nil)
794 req = req.WithContext(ctxWithScopes("admin:*:*"))856 req = req.WithContext(ctxWithScopes("admin:*:*"))
M internal/app/run.go +1 -1
@@ -333,7 +333,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
333333 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
334334
335335 // Create admin handler for admin API
336- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)
336+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log)
337337
338338 return svc, nil
339339 }
@@ -333,7 +333,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
333 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)333 logSvc := logs.NewService(resolveLogFilePath(cfg), svc.containerSvc, svc.runtime, log)
334 334
335 // Create admin handler for admin API335 // Create admin handler for admin API
336- svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.eventBus, log)336+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, healthSvc, secretSvc, logSvc, svc.registrySvc, svc.eventBus, log)
337 337
338 return svc, nil338 return svc, nil
339 }339 }
A pkg/validation/image.go +40 -0
@@ -0,0 +1,40 @@
1+package validation
2+
3+import "strings"
4+
5+// ParseImageReference parses an image reference into name and tag/digest.
6+// Supports formats:
7+// - image:tag (default tag is "latest")
8+// - image@sha256:... (digest)
9+// - image (defaults to "latest")
10+// - registry.example.com/image:tag
11+// - registry.example.com/image@sha256:...
12+//
13+// Returns:
14+// - name: the image name (including registry if specified)
15+// - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...")
16+func ParseImageReference(imageRef string) (string, string) {
17+ // Check for digest reference (@sha256:...)
18+ if idx := strings.Index(imageRef, "@sha256:"); idx != -1 {
19+ return imageRef[:idx], imageRef[idx+1:] // Return name and full digest
20+ }
21+
22+ // Check for tag reference (:tag)
23+ if idx := strings.Index(imageRef, ":"); idx != -1 {
24+ // Check if this is actually a port number (e.g., registry:5000/image)
25+ // by looking for a slash before the colon
26+ if slashIdx := strings.Index(imageRef, "/"); slashIdx != -1 && slashIdx > idx {
27+ // Slash comes after colon, so colon is part of registry port
28+ // Look for another colon for the tag
29+ if tagIdx := strings.Index(imageRef[slashIdx:], ":"); tagIdx != -1 {
30+ actualTagIdx := slashIdx + tagIdx
31+ return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:]
32+ }
33+ return imageRef, "latest"
34+ }
35+ return imageRef[:idx], imageRef[idx+1:] // Return name and tag
36+ }
37+
38+ // Default to "latest" tag
39+ return imageRef, "latest"
40+}
@@ -0,0 +1,40 @@
1+package validation
2+
3+import "strings"
4+
5+// ParseImageReference parses an image reference into name and tag/digest.
6+// Supports formats:
7+// - image:tag (default tag is "latest")
8+// - image@sha256:... (digest)
9+// - image (defaults to "latest")
10+// - registry.example.com/image:tag
11+// - registry.example.com/image@sha256:...
12+//
13+// Returns:
14+// - name: the image name (including registry if specified)
15+// - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...")
16+func ParseImageReference(imageRef string) (string, string) {
17+ // Check for digest reference (@sha256:...)
18+ if idx := strings.Index(imageRef, "@sha256:"); idx != -1 {
19+ return imageRef[:idx], imageRef[idx+1:] // Return name and full digest
20+ }
21+
22+ // Check for tag reference (:tag)
23+ if idx := strings.Index(imageRef, ":"); idx != -1 {
24+ // Check if this is actually a port number (e.g., registry:5000/image)
25+ // by looking for a slash before the colon
26+ if slashIdx := strings.Index(imageRef, "/"); slashIdx != -1 && slashIdx > idx {
27+ // Slash comes after colon, so colon is part of registry port
28+ // Look for another colon for the tag
29+ if tagIdx := strings.Index(imageRef[slashIdx:], ":"); tagIdx != -1 {
30+ actualTagIdx := slashIdx + tagIdx
31+ return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:]
32+ }
33+ return imageRef, "latest"
34+ }
35+ return imageRef[:idx], imageRef[idx+1:] // Return name and tag
36+ }
37+
38+ // Default to "latest" tag
39+ return imageRef, "latest"
40+}
A pkg/validation/image_test.go +91 -0
@@ -0,0 +1,91 @@
1+package validation
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestParseImageReference(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ imageRef string
13+ wantName string
14+ wantRef string
15+ }{
16+ {
17+ name: "simple image with latest tag",
18+ imageRef: "myapp:latest",
19+ wantName: "myapp",
20+ wantRef: "latest",
21+ },
22+ {
23+ name: "simple image with custom tag",
24+ imageRef: "myapp:v1.0.0",
25+ wantName: "myapp",
26+ wantRef: "v1.0.0",
27+ },
28+ {
29+ name: "simple image with digest",
30+ imageRef: "myapp@sha256:abc123def456",
31+ wantName: "myapp",
32+ wantRef: "sha256:abc123def456",
33+ },
34+ {
35+ name: "registry with image and tag",
36+ imageRef: "registry.example.com/myapp:latest",
37+ wantName: "registry.example.com/myapp",
38+ wantRef: "latest",
39+ },
40+ {
41+ name: "registry with port and image and tag",
42+ imageRef: "registry.example.com:5000/myapp:v1.0",
43+ wantName: "registry.example.com:5000/myapp",
44+ wantRef: "v1.0",
45+ },
46+ {
47+ name: "registry with port, image, and digest",
48+ imageRef: "registry.example.com:5000/myapp@sha256:abc123",
49+ wantName: "registry.example.com:5000/myapp",
50+ wantRef: "sha256:abc123",
51+ },
52+ {
53+ name: "simple image without tag (defaults to latest)",
54+ imageRef: "myapp",
55+ wantName: "myapp",
56+ wantRef: "latest",
57+ },
58+ {
59+ name: "registry with image without tag",
60+ imageRef: "registry.example.com/myapp",
61+ wantName: "registry.example.com/myapp",
62+ wantRef: "latest",
63+ },
64+ {
65+ name: "nested path image with tag",
66+ imageRef: "registry.example.com/path/to/myapp:latest",
67+ wantName: "registry.example.com/path/to/myapp",
68+ wantRef: "latest",
69+ },
70+ {
71+ name: "localhost registry with port",
72+ imageRef: "localhost:5000/myapp:latest",
73+ wantName: "localhost:5000/myapp",
74+ wantRef: "latest",
75+ },
76+ {
77+ name: "IP address registry with port",
78+ imageRef: "192.168.1.100:5000/myapp:latest",
79+ wantName: "192.168.1.100:5000/myapp",
80+ wantRef: "latest",
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ gotName, gotRef := ParseImageReference(tt.imageRef)
87+ assert.Equal(t, tt.wantName, gotName, "name mismatch")
88+ assert.Equal(t, tt.wantRef, gotRef, "reference mismatch")
89+ })
90+ }
91+}
@@ -0,0 +1,91 @@
1+package validation
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestParseImageReference(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ imageRef string
13+ wantName string
14+ wantRef string
15+ }{
16+ {
17+ name: "simple image with latest tag",
18+ imageRef: "myapp:latest",
19+ wantName: "myapp",
20+ wantRef: "latest",
21+ },
22+ {
23+ name: "simple image with custom tag",
24+ imageRef: "myapp:v1.0.0",
25+ wantName: "myapp",
26+ wantRef: "v1.0.0",
27+ },
28+ {
29+ name: "simple image with digest",
30+ imageRef: "myapp@sha256:abc123def456",
31+ wantName: "myapp",
32+ wantRef: "sha256:abc123def456",
33+ },
34+ {
35+ name: "registry with image and tag",
36+ imageRef: "registry.example.com/myapp:latest",
37+ wantName: "registry.example.com/myapp",
38+ wantRef: "latest",
39+ },
40+ {
41+ name: "registry with port and image and tag",
42+ imageRef: "registry.example.com:5000/myapp:v1.0",
43+ wantName: "registry.example.com:5000/myapp",
44+ wantRef: "v1.0",
45+ },
46+ {
47+ name: "registry with port, image, and digest",
48+ imageRef: "registry.example.com:5000/myapp@sha256:abc123",
49+ wantName: "registry.example.com:5000/myapp",
50+ wantRef: "sha256:abc123",
51+ },
52+ {
53+ name: "simple image without tag (defaults to latest)",
54+ imageRef: "myapp",
55+ wantName: "myapp",
56+ wantRef: "latest",
57+ },
58+ {
59+ name: "registry with image without tag",
60+ imageRef: "registry.example.com/myapp",
61+ wantName: "registry.example.com/myapp",
62+ wantRef: "latest",
63+ },
64+ {
65+ name: "nested path image with tag",
66+ imageRef: "registry.example.com/path/to/myapp:latest",
67+ wantName: "registry.example.com/path/to/myapp",
68+ wantRef: "latest",
69+ },
70+ {
71+ name: "localhost registry with port",
72+ imageRef: "localhost:5000/myapp:latest",
73+ wantName: "localhost:5000/myapp",
74+ wantRef: "latest",
75+ },
76+ {
77+ name: "IP address registry with port",
78+ imageRef: "192.168.1.100:5000/myapp:latest",
79+ wantName: "192.168.1.100:5000/myapp",
80+ wantRef: "latest",
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, func(t *testing.T) {
86+ gotName, gotRef := ParseImageReference(tt.imageRef)
87+ assert.Equal(t, tt.wantName, gotName, "name mismatch")
88+ assert.Equal(t, tt.wantRef, gotRef, "reference mismatch")
89+ })
90+ }
91+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close