detached 8d102b6

feat(admin): validate image exists in registry before adding route

commit
8d102b655740b5b8f554ee54d382142eeb0853bc
author
bnema · · 2026-01-28 20:45 UTC
parent
7a29f8a
signature
unsigned
5 files · +236 -21
M internal/adapters/in/http/admin/handler.go +14 -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,16 @@ 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+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
286+ 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))
287+ return
288+ }
289+ }
290+
277291 if err := h.configSvc.AddRoute(ctx, route); err != nil {
278292 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
279293 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,16 @@ 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+ log.Error().Err(err).Str("image", route.Image).Msg("image not found in registry")
286+ 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))
287+ return
288+ }
289+ }
290+
277 if err := h.configSvc.AddRoute(ctx, route); err != nil {291 if err := h.configSvc.AddRoute(ctx, route); err != nil {
278 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")292 log.Error().Err(err).Str("domain", route.Domain).Msg("failed to add route")
279 switch {293 switch {
M internal/adapters/in/http/admin/handler_test.go +81 -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
@@ -198,7 +202,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198202 containerSvc := inmocks.NewMockContainerService(t)
199203 secretSvc := inmocks.NewMockSecretService(t)
200204
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
205+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
202206
203207 tests := []struct {
204208 name string
@@ -242,7 +246,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242246 containerSvc := inmocks.NewMockContainerService(t)
243247 secretSvc := inmocks.NewMockSecretService(t)
244248
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
249+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
246250
247251 tests := []struct {
248252 name string
@@ -299,7 +303,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299303 containerSvc := inmocks.NewMockContainerService(t)
300304 secretSvc := inmocks.NewMockSecretService(t)
301305
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
306+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
303307
304308 secretsJSON := `{"API_KEY": "secret123"}`
305309
@@ -348,7 +352,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348352 containerSvc := inmocks.NewMockContainerService(t)
349353 secretSvc := inmocks.NewMockSecretService(t)
350354
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
355+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
352356
353357 tests := []struct {
354358 name string
@@ -392,7 +396,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392396 containerSvc := inmocks.NewMockContainerService(t)
393397 secretSvc := inmocks.NewMockSecretService(t)
394398
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
399+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
396400
397401 tests := []struct {
398402 name string
@@ -446,7 +450,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446450 containerSvc := inmocks.NewMockContainerService(t)
447451 secretSvc := inmocks.NewMockSecretService(t)
448452
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
453+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
450454
451455 tests := []struct {
452456 name string
@@ -503,7 +507,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503507 containerSvc := inmocks.NewMockContainerService(t)
504508 secretSvc := inmocks.NewMockSecretService(t)
505509
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
510+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507511
508512 tests := []struct {
509513 name string
@@ -557,7 +561,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557561 containerSvc := inmocks.NewMockContainerService(t)
558562 secretSvc := inmocks.NewMockSecretService(t)
559563
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
561565
562566 expectedRoutes := []domain.Route{
563567 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -592,7 +596,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
592596 containerSvc := inmocks.NewMockContainerService(t)
593597 secretSvc := inmocks.NewMockSecretService(t)
594598
595- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
599+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
596600
597601 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
598602 Domain: "app.example.com", Image: "app:latest",
@@ -623,7 +627,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
623627 containerSvc := inmocks.NewMockContainerService(t)
624628 secretSvc := inmocks.NewMockSecretService(t)
625629
626- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
630+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
627631
628632 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
629633
@@ -665,10 +669,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
665669 authSvc := inmocks.NewMockAuthService(t)
666670 containerSvc := inmocks.NewMockContainerService(t)
667671 secretSvc := inmocks.NewMockSecretService(t)
672+ registrySvc := inmocks.NewMockRegistryService(t)
668673
669- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
674+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
670675
671676 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
677+ registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
672678
673679 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
674680 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -681,13 +687,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
681687 }
682688 }
683689
690+func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
691+ configSvc := inmocks.NewMockConfigService(t)
692+ authSvc := inmocks.NewMockAuthService(t)
693+ containerSvc := inmocks.NewMockContainerService(t)
694+ secretSvc := inmocks.NewMockSecretService(t)
695+ registrySvc := inmocks.NewMockRegistryService(t)
696+
697+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
698+
699+ tests := []struct {
700+ name string
701+ image string
702+ body string
703+ manifestName string
704+ manifestRef string
705+ }{
706+ {
707+ name: "tagged image not found",
708+ image: "myapp:latest",
709+ body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
710+ manifestName: "myapp",
711+ manifestRef: "latest",
712+ },
713+ {
714+ name: "digest image not found",
715+ image: "myapp@sha256:abc123",
716+ body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
717+ manifestName: "myapp",
718+ manifestRef: "sha256:abc123",
719+ },
720+ {
721+ name: "implicit latest tag not found",
722+ image: "myapp",
723+ body: `{"domain": "app.example.com", "image": "myapp"}`,
724+ manifestName: "myapp",
725+ manifestRef: "latest",
726+ },
727+ }
728+
729+ for _, tt := range tests {
730+ t.Run(tt.name, func(t *testing.T) {
731+ registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, fmt.Errorf("manifest not found"))
732+
733+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
734+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
735+ rec := httptest.NewRecorder()
736+
737+ handler.ServeHTTP(rec, req)
738+
739+ assert.Equal(t, http.StatusBadRequest, rec.Code)
740+ assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
741+ })
742+ }
743+}
744+
684745 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
685746 configSvc := inmocks.NewMockConfigService(t)
686747 authSvc := inmocks.NewMockAuthService(t)
687748 containerSvc := inmocks.NewMockContainerService(t)
688749 secretSvc := inmocks.NewMockSecretService(t)
689750
690- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
751+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
691752
692753 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
693754 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -704,7 +765,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
704765 containerSvc := inmocks.NewMockContainerService(t)
705766 secretSvc := inmocks.NewMockSecretService(t)
706767
707- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
768+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
708769
709770 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
710771 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -721,7 +782,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
721782 containerSvc := inmocks.NewMockContainerService(t)
722783 secretSvc := inmocks.NewMockSecretService(t)
723784
724- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
785+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
725786
726787 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
727788 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -738,7 +799,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
738799 containerSvc := inmocks.NewMockContainerService(t)
739800 secretSvc := inmocks.NewMockSecretService(t)
740801
741- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
802+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
742803
743804 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
744805 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -755,7 +816,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
755816 containerSvc := inmocks.NewMockContainerService(t)
756817 secretSvc := inmocks.NewMockSecretService(t)
757818
758- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
819+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
759820
760821 tests := []struct {
761822 method string
@@ -788,7 +849,7 @@ func TestHandler_NotFound(t *testing.T) {
788849 containerSvc := inmocks.NewMockContainerService(t)
789850 secretSvc := inmocks.NewMockSecretService(t)
790851
791- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())
852+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
792853
793854 req := httptest.NewRequest("GET", "/admin/unknown", nil)
794855 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
@@ -198,7 +202,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
198 containerSvc := inmocks.NewMockContainerService(t)202 containerSvc := inmocks.NewMockContainerService(t)
199 secretSvc := inmocks.NewMockSecretService(t)203 secretSvc := inmocks.NewMockSecretService(t)
200 204
201- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())205+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
202 206
203 tests := []struct {207 tests := []struct {
204 name string208 name string
@@ -242,7 +246,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
242 containerSvc := inmocks.NewMockContainerService(t)246 containerSvc := inmocks.NewMockContainerService(t)
243 secretSvc := inmocks.NewMockSecretService(t)247 secretSvc := inmocks.NewMockSecretService(t)
244 248
245- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())249+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
246 250
247 tests := []struct {251 tests := []struct {
248 name string252 name string
@@ -299,7 +303,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
299 containerSvc := inmocks.NewMockContainerService(t)303 containerSvc := inmocks.NewMockContainerService(t)
300 secretSvc := inmocks.NewMockSecretService(t)304 secretSvc := inmocks.NewMockSecretService(t)
301 305
302- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())306+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
303 307
304 secretsJSON := `{"API_KEY": "secret123"}`308 secretsJSON := `{"API_KEY": "secret123"}`
305 309
@@ -348,7 +352,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
348 containerSvc := inmocks.NewMockContainerService(t)352 containerSvc := inmocks.NewMockContainerService(t)
349 secretSvc := inmocks.NewMockSecretService(t)353 secretSvc := inmocks.NewMockSecretService(t)
350 354
351- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())355+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
352 356
353 tests := []struct {357 tests := []struct {
354 name string358 name string
@@ -392,7 +396,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
392 containerSvc := inmocks.NewMockContainerService(t)396 containerSvc := inmocks.NewMockContainerService(t)
393 secretSvc := inmocks.NewMockSecretService(t)397 secretSvc := inmocks.NewMockSecretService(t)
394 398
395- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())399+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
396 400
397 tests := []struct {401 tests := []struct {
398 name string402 name string
@@ -446,7 +450,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
446 containerSvc := inmocks.NewMockContainerService(t)450 containerSvc := inmocks.NewMockContainerService(t)
447 secretSvc := inmocks.NewMockSecretService(t)451 secretSvc := inmocks.NewMockSecretService(t)
448 452
449- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())453+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
450 454
451 tests := []struct {455 tests := []struct {
452 name string456 name string
@@ -503,7 +507,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
503 containerSvc := inmocks.NewMockContainerService(t)507 containerSvc := inmocks.NewMockContainerService(t)
504 secretSvc := inmocks.NewMockSecretService(t)508 secretSvc := inmocks.NewMockSecretService(t)
505 509
506- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())510+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507 511
508 tests := []struct {512 tests := []struct {
509 name string513 name string
@@ -557,7 +561,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
557 containerSvc := inmocks.NewMockContainerService(t)561 containerSvc := inmocks.NewMockContainerService(t)
558 secretSvc := inmocks.NewMockSecretService(t)562 secretSvc := inmocks.NewMockSecretService(t)
559 563
560- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
561 565
562 expectedRoutes := []domain.Route{566 expectedRoutes := []domain.Route{
563 {Domain: "app1.example.com", Image: "app1:latest"},567 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -592,7 +596,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
592 containerSvc := inmocks.NewMockContainerService(t)596 containerSvc := inmocks.NewMockContainerService(t)
593 secretSvc := inmocks.NewMockSecretService(t)597 secretSvc := inmocks.NewMockSecretService(t)
594 598
595- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())599+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
596 600
597 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{601 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
598 Domain: "app.example.com", Image: "app:latest",602 Domain: "app.example.com", Image: "app:latest",
@@ -623,7 +627,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
623 containerSvc := inmocks.NewMockContainerService(t)627 containerSvc := inmocks.NewMockContainerService(t)
624 secretSvc := inmocks.NewMockSecretService(t)628 secretSvc := inmocks.NewMockSecretService(t)
625 629
626- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())630+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
627 631
628 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)632 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
629 633
@@ -665,10 +669,12 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
665 authSvc := inmocks.NewMockAuthService(t)669 authSvc := inmocks.NewMockAuthService(t)
666 containerSvc := inmocks.NewMockContainerService(t)670 containerSvc := inmocks.NewMockContainerService(t)
667 secretSvc := inmocks.NewMockSecretService(t)671 secretSvc := inmocks.NewMockSecretService(t)
672+ registrySvc := inmocks.NewMockRegistryService(t)
668 673
669- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())674+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
670 675
671 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)676 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
677+ registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
672 678
673 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))679 req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
674 req = req.WithContext(ctxWithScopes("admin:routes:write"))680 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -681,13 +687,68 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
681 }687 }
682 }688 }
683 689
690+func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
691+ configSvc := inmocks.NewMockConfigService(t)
692+ authSvc := inmocks.NewMockAuthService(t)
693+ containerSvc := inmocks.NewMockContainerService(t)
694+ secretSvc := inmocks.NewMockSecretService(t)
695+ registrySvc := inmocks.NewMockRegistryService(t)
696+
697+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
698+
699+ tests := []struct {
700+ name string
701+ image string
702+ body string
703+ manifestName string
704+ manifestRef string
705+ }{
706+ {
707+ name: "tagged image not found",
708+ image: "myapp:latest",
709+ body: `{"domain": "app.example.com", "image": "myapp:latest"}`,
710+ manifestName: "myapp",
711+ manifestRef: "latest",
712+ },
713+ {
714+ name: "digest image not found",
715+ image: "myapp@sha256:abc123",
716+ body: `{"domain": "app.example.com", "image": "myapp@sha256:abc123"}`,
717+ manifestName: "myapp",
718+ manifestRef: "sha256:abc123",
719+ },
720+ {
721+ name: "implicit latest tag not found",
722+ image: "myapp",
723+ body: `{"domain": "app.example.com", "image": "myapp"}`,
724+ manifestName: "myapp",
725+ manifestRef: "latest",
726+ },
727+ }
728+
729+ for _, tt := range tests {
730+ t.Run(tt.name, func(t *testing.T) {
731+ registrySvc.EXPECT().GetManifest(mock.Anything, tt.manifestName, tt.manifestRef).Return(nil, fmt.Errorf("manifest not found"))
732+
733+ req := httptest.NewRequest("POST", "/admin/routes", bytes.NewBufferString(tt.body))
734+ req = req.WithContext(ctxWithScopes("admin:routes:write"))
735+ rec := httptest.NewRecorder()
736+
737+ handler.ServeHTTP(rec, req)
738+
739+ assert.Equal(t, http.StatusBadRequest, rec.Code)
740+ assert.Contains(t, rec.Body.String(), fmt.Sprintf("image '%s' not found", tt.image))
741+ })
742+ }
743+}
744+
684 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {745 func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
685 configSvc := inmocks.NewMockConfigService(t)746 configSvc := inmocks.NewMockConfigService(t)
686 authSvc := inmocks.NewMockAuthService(t)747 authSvc := inmocks.NewMockAuthService(t)
687 containerSvc := inmocks.NewMockContainerService(t)748 containerSvc := inmocks.NewMockContainerService(t)
688 secretSvc := inmocks.NewMockSecretService(t)749 secretSvc := inmocks.NewMockSecretService(t)
689 750
690- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())751+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
691 752
692 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))753 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
693 req = req.WithContext(ctxWithScopes("admin:routes:write"))754 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -704,7 +765,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
704 containerSvc := inmocks.NewMockContainerService(t)765 containerSvc := inmocks.NewMockContainerService(t)
705 secretSvc := inmocks.NewMockSecretService(t)766 secretSvc := inmocks.NewMockSecretService(t)
706 767
707- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())768+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
708 769
709 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)770 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
710 req = req.WithContext(ctxWithScopes("admin:routes:write"))771 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -721,7 +782,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
721 containerSvc := inmocks.NewMockContainerService(t)782 containerSvc := inmocks.NewMockContainerService(t)
722 secretSvc := inmocks.NewMockSecretService(t)783 secretSvc := inmocks.NewMockSecretService(t)
723 784
724- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())785+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
725 786
726 req := httptest.NewRequest("GET", "/admin/secrets/", nil)787 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
727 req = req.WithContext(ctxWithScopes("admin:secrets:read"))788 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -738,7 +799,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
738 containerSvc := inmocks.NewMockContainerService(t)799 containerSvc := inmocks.NewMockContainerService(t)
739 secretSvc := inmocks.NewMockSecretService(t)800 secretSvc := inmocks.NewMockSecretService(t)
740 801
741- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())802+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
742 803
743 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)804 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
744 req = req.WithContext(ctxWithScopes("admin:secrets:write"))805 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -755,7 +816,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
755 containerSvc := inmocks.NewMockContainerService(t)816 containerSvc := inmocks.NewMockContainerService(t)
756 secretSvc := inmocks.NewMockSecretService(t)817 secretSvc := inmocks.NewMockSecretService(t)
757 818
758- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())819+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
759 820
760 tests := []struct {821 tests := []struct {
761 method string822 method string
@@ -788,7 +849,7 @@ func TestHandler_NotFound(t *testing.T) {
788 containerSvc := inmocks.NewMockContainerService(t)849 containerSvc := inmocks.NewMockContainerService(t)
789 secretSvc := inmocks.NewMockSecretService(t)850 secretSvc := inmocks.NewMockSecretService(t)
790 851
791- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, nil, testLogger())852+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
792 853
793 req := httptest.NewRequest("GET", "/admin/unknown", nil)854 req := httptest.NewRequest("GET", "/admin/unknown", nil)
794 req = req.WithContext(ctxWithScopes("admin:*:*"))855 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 +49 -0
@@ -0,0 +1,49 @@
1+package validation
2+
3+// ParseImageReference parses an image reference into name and tag/digest.
4+// Supports formats:
5+// - image:tag (default tag is "latest")
6+// - image@sha256:... (digest)
7+// - image (defaults to "latest")
8+// - registry.example.com/image:tag
9+// - registry.example.com/image@sha256:...
10+//
11+// Returns:
12+// - name: the image name (including registry if specified)
13+// - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...")
14+func ParseImageReference(imageRef string) (string, string) {
15+ // Check for digest reference (@sha256:...)
16+ if idx := indexString(imageRef, "@sha256:"); idx != -1 {
17+ return imageRef[:idx], imageRef[idx+1:] // Return name and full digest
18+ }
19+
20+ // Check for tag reference (:tag)
21+ if idx := indexString(imageRef, ":"); idx != -1 {
22+ // Check if this is actually a port number (e.g., registry:5000/image)
23+ // by looking for a slash before the colon
24+ if slashIdx := indexString(imageRef, "/"); slashIdx != -1 && slashIdx > idx {
25+ // Slash comes after colon, so colon is part of registry port
26+ // Look for another colon for the tag
27+ if tagIdx := indexString(imageRef[slashIdx:], ":"); tagIdx != -1 {
28+ actualTagIdx := slashIdx + tagIdx
29+ return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:]
30+ }
31+ return imageRef, "latest"
32+ }
33+ return imageRef[:idx], imageRef[idx+1:] // Return name and tag
34+ }
35+
36+ // Default to "latest" tag
37+ return imageRef, "latest"
38+}
39+
40+// indexString returns the index of the first occurrence of substr in s, or -1 if not found.
41+// This is a helper function that mirrors strings.Index for testing purposes.
42+func indexString(s, substr string) int {
43+ for i := 0; i <= len(s)-len(substr); i++ {
44+ if s[i:i+len(substr)] == substr {
45+ return i
46+ }
47+ }
48+ return -1
49+}
@@ -0,0 +1,49 @@
1+package validation
2+
3+// ParseImageReference parses an image reference into name and tag/digest.
4+// Supports formats:
5+// - image:tag (default tag is "latest")
6+// - image@sha256:... (digest)
7+// - image (defaults to "latest")
8+// - registry.example.com/image:tag
9+// - registry.example.com/image@sha256:...
10+//
11+// Returns:
12+// - name: the image name (including registry if specified)
13+// - reference: the tag (e.g., "latest") or digest (e.g., "sha256:...")
14+func ParseImageReference(imageRef string) (string, string) {
15+ // Check for digest reference (@sha256:...)
16+ if idx := indexString(imageRef, "@sha256:"); idx != -1 {
17+ return imageRef[:idx], imageRef[idx+1:] // Return name and full digest
18+ }
19+
20+ // Check for tag reference (:tag)
21+ if idx := indexString(imageRef, ":"); idx != -1 {
22+ // Check if this is actually a port number (e.g., registry:5000/image)
23+ // by looking for a slash before the colon
24+ if slashIdx := indexString(imageRef, "/"); slashIdx != -1 && slashIdx > idx {
25+ // Slash comes after colon, so colon is part of registry port
26+ // Look for another colon for the tag
27+ if tagIdx := indexString(imageRef[slashIdx:], ":"); tagIdx != -1 {
28+ actualTagIdx := slashIdx + tagIdx
29+ return imageRef[:actualTagIdx], imageRef[actualTagIdx+1:]
30+ }
31+ return imageRef, "latest"
32+ }
33+ return imageRef[:idx], imageRef[idx+1:] // Return name and tag
34+ }
35+
36+ // Default to "latest" tag
37+ return imageRef, "latest"
38+}
39+
40+// indexString returns the index of the first occurrence of substr in s, or -1 if not found.
41+// This is a helper function that mirrors strings.Index for testing purposes.
42+func indexString(s, substr string) int {
43+ for i := 0; i <= len(s)-len(substr); i++ {
44+ if s[i:i+len(substr)] == substr {
45+ return i
46+ }
47+ }
48+ return -1
49+}
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