detached 566e834

fix: normalize bootstrap image to registry/name format before storing

Bootstrap now normalizes bare image names (e.g. 'pitlane') by prepending
the configured registry domain, and strips tags from fully qualified images
before storing the route. This ensures push can parse the stored route image.

Close #101
commit
566e834c67863a45d3a3759f88612bc15c464e9c
author
brice · · 2026-03-14 04:29 UTC
merge of
b477285 + e618b8c · diff against the first parent
signature
unsigned
6 files · +139 -18
M internal/adapters/in/cli/controlplane_local.go +13 -3
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/gordon/internal/app"
1212 "github.com/bnema/gordon/internal/boundaries/in"
1313 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/internal/usecase/config"
1415 )
1516
1617 type localControlPlane struct {
@@ -124,10 +125,19 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string)
124125 }
125126
126127 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
128+ registryDomain := ""
129+ if l.configSvc != nil {
130+ registryDomain = l.configSvc.GetRegistryDomain()
131+ }
132+ normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
133+ if err != nil {
134+ return nil, fmt.Errorf("invalid image: %w", err)
135+ }
136+
127137 resp := &dto.BootstrapResponse{
128138 Domain: req.Domain,
129- Image: req.Image,
130- Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
139+ Image: normalizedImage,
140+ Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
131141 }
132142
133143 if l.configSvc == nil {
@@ -142,7 +152,7 @@ func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequ
142152 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
143153 }
144154
145- err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
155+ err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
146156 switch err {
147157 case nil:
148158 addStep("route", "configured")
@@ -11,6 +11,7 @@ import (
11 "github.com/bnema/gordon/internal/app"11 "github.com/bnema/gordon/internal/app"
12 "github.com/bnema/gordon/internal/boundaries/in"12 "github.com/bnema/gordon/internal/boundaries/in"
13 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
14+ "github.com/bnema/gordon/internal/usecase/config"
14 )15 )
15 16
16 type localControlPlane struct {17 type localControlPlane struct {
@@ -124,10 +125,19 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string)
124 }125 }
125 126
126 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {127 func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
128+ registryDomain := ""
129+ if l.configSvc != nil {
130+ registryDomain = l.configSvc.GetRegistryDomain()
131+ }
132+ normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
133+ if err != nil {
134+ return nil, fmt.Errorf("invalid image: %w", err)
135+ }
136+
127 resp := &dto.BootstrapResponse{137 resp := &dto.BootstrapResponse{
128 Domain: req.Domain,138 Domain: req.Domain,
129- Image: req.Image,139+ Image: normalizedImage,
130- Next: fmt.Sprintf("push %s to trigger deployment", req.Image),140+ Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
131 }141 }
132 142
133 if l.configSvc == nil {143 if l.configSvc == nil {
@@ -142,7 +152,7 @@ func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequ
142 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})152 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
143 }153 }
144 154
145- err := l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})155+ err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
146 switch err {156 switch err {
147 case nil:157 case nil:
148 addStep("route", "configured")158 addStep("route", "configured")
M internal/adapters/in/http/admin/bootstrap_test.go +42 -8
@@ -36,7 +36,8 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
3636 },
3737 }
3838
39- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
39+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
40+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
4041 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
4142 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
4243 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -56,8 +57,8 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
5657 var response dto.BootstrapResponse
5758 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
5859 assert.Equal(t, payload.Domain, response.Domain)
59- assert.Equal(t, payload.Image, response.Image)
60- assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
60+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
61+ assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
6162 assert.Equal(t, []dto.BootstrapStep{
6263 {Name: "route", Status: "configured"},
6364 {Name: "attachment:postgres:16", Status: "created"},
@@ -84,12 +85,13 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
8485 },
8586 }
8687
87- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
88+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)
89+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
8890 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
8991 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
9092 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
9193
92- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
94+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
9395 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
9496 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
9597 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -109,7 +111,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
109111 var response dto.BootstrapResponse
110112 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111113 assert.Equal(t, payload.Domain, response.Domain)
112- assert.Equal(t, payload.Image, response.Image)
114+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
113115 require.Len(t, response.Steps, 4)
114116 if i == 0 {
115117 assert.Equal(t, []dto.BootstrapStep{
@@ -171,6 +173,36 @@ func TestBootstrap_MissingImage(t *testing.T) {
171173 assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172174 }
173175
176+func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
177+ configSvc := inmocks.NewMockConfigService(t)
178+ authSvc := inmocks.NewMockAuthService(t)
179+ containerSvc := inmocks.NewMockContainerService(t)
180+ secretSvc := inmocks.NewMockSecretService(t)
181+
182+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
183+
184+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
185+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
186+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane"}).Return(nil).Once()
187+
188+ body, err := json.Marshal(payload)
189+ require.NoError(t, err)
190+
191+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
192+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
193+ rec := httptest.NewRecorder()
194+
195+ handler.ServeHTTP(rec, req)
196+
197+ assert.Equal(t, http.StatusOK, rec.Code)
198+
199+ var response dto.BootstrapResponse
200+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
201+ assert.Equal(t, payload.Domain, response.Domain)
202+ assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
203+ assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
204+}
205+
174206 func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175207 tests := []struct {
176208 name string
@@ -196,7 +228,8 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
196228 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197229
198230 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()
231+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
232+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(tt.mockErr).Once()
200233
201234 body, err := json.Marshal(payload)
202235 require.NoError(t, err)
@@ -227,7 +260,8 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
227260 Attachments: []string{"postgres:16"},
228261 }
229262
230- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()
263+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
264+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
231265 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232266
233267 body, err := json.Marshal(payload)
@@ -36,7 +36,8 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
36 },36 },
37 }37 }
38 38
39- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()39+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
40+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
40 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()41 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
41 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()42 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
42 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()43 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -56,8 +57,8 @@ func TestBootstrap_FullWorkflow_HappyPath(t *testing.T) {
56 var response dto.BootstrapResponse57 var response dto.BootstrapResponse
57 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))58 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
58 assert.Equal(t, payload.Domain, response.Domain)59 assert.Equal(t, payload.Domain, response.Domain)
59- assert.Equal(t, payload.Image, response.Image)60+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
60- assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)61+ assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
61 assert.Equal(t, []dto.BootstrapStep{62 assert.Equal(t, []dto.BootstrapStep{
62 {Name: "route", Status: "configured"},63 {Name: "route", Status: "configured"},
63 {Name: "attachment:postgres:16", Status: "created"},64 {Name: "attachment:postgres:16", Status: "created"},
@@ -84,12 +85,13 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
84 },85 },
85 }86 }
86 87
87- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()88+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Times(2)
89+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
88 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()90 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once()
89 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()91 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
90 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()92 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
91 93
92- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()94+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
93 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()95 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once()
94 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()96 secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
95 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()97 secretSvc.EXPECT().SetAttachment(mock.Anything, payload.Domain, "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -109,7 +111,7 @@ func TestBootstrap_Idempotent_Rerun(t *testing.T) {
109 var response dto.BootstrapResponse111 var response dto.BootstrapResponse
110 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))112 require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
111 assert.Equal(t, payload.Domain, response.Domain)113 assert.Equal(t, payload.Domain, response.Domain)
112- assert.Equal(t, payload.Image, response.Image)114+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
113 require.Len(t, response.Steps, 4)115 require.Len(t, response.Steps, 4)
114 if i == 0 {116 if i == 0 {
115 assert.Equal(t, []dto.BootstrapStep{117 assert.Equal(t, []dto.BootstrapStep{
@@ -171,6 +173,36 @@ func TestBootstrap_MissingImage(t *testing.T) {
171 assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())173 assert.JSONEq(t, `{"error":"image is required"}`+"\n", rec.Body.String())
172 }174 }
173 175
176+func TestBootstrap_NormalizesImageBeforeStoring(t *testing.T) {
177+ configSvc := inmocks.NewMockConfigService(t)
178+ authSvc := inmocks.NewMockAuthService(t)
179+ containerSvc := inmocks.NewMockContainerService(t)
180+ secretSvc := inmocks.NewMockSecretService(t)
181+
182+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
183+
184+ payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "pitlane:v1"}
185+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
186+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/pitlane"}).Return(nil).Once()
187+
188+ body, err := json.Marshal(payload)
189+ require.NoError(t, err)
190+
191+ req := httptest.NewRequest(http.MethodPost, "/admin/bootstrap", bytes.NewReader(body))
192+ req = req.WithContext(ctxWithScopes("admin:routes:write", "admin:config:write"))
193+ rec := httptest.NewRecorder()
194+
195+ handler.ServeHTTP(rec, req)
196+
197+ assert.Equal(t, http.StatusOK, rec.Code)
198+
199+ var response dto.BootstrapResponse
200+ require.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
201+ assert.Equal(t, payload.Domain, response.Domain)
202+ assert.Equal(t, "reg.bnema.dev/pitlane", response.Image)
203+ assert.Equal(t, "push reg.bnema.dev/pitlane to trigger deployment", response.Next)
204+}
205+
174 func TestBootstrap_AddRouteValidationErrors(t *testing.T) {206 func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
175 tests := []struct {207 tests := []struct {
176 name string208 name string
@@ -196,7 +228,8 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) {
196 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)228 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
197 229
198 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}230 payload := dto.BootstrapRequest{Domain: "app.example.com", Image: "myapp:latest"}
199- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(tt.mockErr).Once()231+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
232+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(tt.mockErr).Once()
200 233
201 body, err := json.Marshal(payload)234 body, err := json.Marshal(payload)
202 require.NoError(t, err)235 require.NoError(t, err)
@@ -227,7 +260,8 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) {
227 Attachments: []string{"postgres:16"},260 Attachments: []string{"postgres:16"},
228 }261 }
229 262
230- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: payload.Image}).Return(nil).Once()263+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
264+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: payload.Domain, Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
231 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()265 configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once()
232 266
233 body, err := json.Marshal(payload)267 body, err := json.Marshal(payload)
M internal/adapters/in/http/admin/handler.go +10 -3
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/gordon/internal/boundaries/in"
2020 "github.com/bnema/gordon/internal/boundaries/out"
2121 "github.com/bnema/gordon/internal/domain"
22+ "github.com/bnema/gordon/internal/usecase/config"
2223 "github.com/bnema/gordon/internal/usecase/registry"
2324 "github.com/bnema/gordon/pkg/validation"
2425 )
@@ -312,6 +313,12 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
312313 return
313314 }
314315
316+ normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain())
317+ if err != nil {
318+ h.sendError(w, http.StatusBadRequest, err.Error())
319+ return
320+ }
321+
315322 if err := h.validateBootstrapPermissions(ctx, req); err != nil {
316323 h.sendError(w, http.StatusForbidden, err.Error())
317324 return
@@ -319,14 +326,14 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
319326
320327 resp := dto.BootstrapResponse{
321328 Domain: req.Domain,
322- Image: req.Image,
323- Next: fmt.Sprintf("push %s to trigger deployment", req.Image),
329+ Image: normalizedImage,
330+ Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
324331 }
325332 addStep := func(name, status string) {
326333 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
327334 }
328335
329- err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})
336+ err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
330337 switch {
331338 case err == nil:
332339 addStep("route", "configured")
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/gordon/internal/boundaries/in"19 "github.com/bnema/gordon/internal/boundaries/in"
20 "github.com/bnema/gordon/internal/boundaries/out"20 "github.com/bnema/gordon/internal/boundaries/out"
21 "github.com/bnema/gordon/internal/domain"21 "github.com/bnema/gordon/internal/domain"
22+ "github.com/bnema/gordon/internal/usecase/config"
22 "github.com/bnema/gordon/internal/usecase/registry"23 "github.com/bnema/gordon/internal/usecase/registry"
23 "github.com/bnema/gordon/pkg/validation"24 "github.com/bnema/gordon/pkg/validation"
24 )25 )
@@ -312,6 +313,12 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
312 return313 return
313 }314 }
314 315
316+ normalizedImage, err := config.NormalizeBootstrapImage(req.Image, h.configSvc.GetRegistryDomain())
317+ if err != nil {
318+ h.sendError(w, http.StatusBadRequest, err.Error())
319+ return
320+ }
321+
315 if err := h.validateBootstrapPermissions(ctx, req); err != nil {322 if err := h.validateBootstrapPermissions(ctx, req); err != nil {
316 h.sendError(w, http.StatusForbidden, err.Error())323 h.sendError(w, http.StatusForbidden, err.Error())
317 return324 return
@@ -319,14 +326,14 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) {
319 326
320 resp := dto.BootstrapResponse{327 resp := dto.BootstrapResponse{
321 Domain: req.Domain,328 Domain: req.Domain,
322- Image: req.Image,329+ Image: normalizedImage,
323- Next: fmt.Sprintf("push %s to trigger deployment", req.Image),330+ Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
324 }331 }
325 addStep := func(name, status string) {332 addStep := func(name, status string) {
326 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})333 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
327 }334 }
328 335
329- err := h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: req.Image})336+ err = h.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
330 switch {337 switch {
331 case err == nil:338 case err == nil:
332 addStep("route", "configured")339 addStep("route", "configured")
M internal/adapters/in/http/admin/handler_test.go +9 -4
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "net/http"
1010 "net/http/httptest"
11+ "strings"
1112 "testing"
1213 "time"
1314
@@ -517,7 +518,8 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
517518
518519 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
519520
520- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
521+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
522+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
521523 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
522524 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
523525 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -545,8 +547,8 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
545547 var response dto.BootstrapResponse
546548 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
547549 assert.Equal(t, "app.example.com", response.Domain)
548- assert.Equal(t, "myapp:latest", response.Image)
549- assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)
550+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
551+ assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
550552 assert.Equal(t, []dto.BootstrapStep{
551553 {Name: "route", Status: "configured"},
552554 {Name: "attachment:postgres:16", Status: "created"},
@@ -577,8 +579,11 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
577579
578580 for _, tt := range tests {
579581 t.Run(tt.name, func(t *testing.T) {
582+ if tt.wantStatus == http.StatusOK || strings.Contains(tt.body, `"env"`) || strings.Contains(tt.body, `"attachments"`) {
583+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
584+ }
580585 if tt.wantStatus == http.StatusOK {
581- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()
586+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
582587 }
583588
584589 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
@@ -8,6 +8,7 @@ import (
8 "fmt"8 "fmt"
9 "net/http"9 "net/http"
10 "net/http/httptest"10 "net/http/httptest"
11+ "strings"
11 "testing"12 "testing"
12 "time"13 "time"
13 14
@@ -517,7 +518,8 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
517 518
518 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)519 handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
519 520
520- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()521+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
522+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
521 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()523 configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once()
522 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()524 secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once()
523 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()525 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "postgres", map[string]string{"POSTGRES_PASSWORD": "secret"}).Return(nil).Once()
@@ -545,8 +547,8 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) {
545 var response dto.BootstrapResponse547 var response dto.BootstrapResponse
546 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))548 assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
547 assert.Equal(t, "app.example.com", response.Domain)549 assert.Equal(t, "app.example.com", response.Domain)
548- assert.Equal(t, "myapp:latest", response.Image)550+ assert.Equal(t, "reg.bnema.dev/myapp", response.Image)
549- assert.Equal(t, "push myapp:latest to trigger deployment", response.Next)551+ assert.Equal(t, "push reg.bnema.dev/myapp to trigger deployment", response.Next)
550 assert.Equal(t, []dto.BootstrapStep{552 assert.Equal(t, []dto.BootstrapStep{
551 {Name: "route", Status: "configured"},553 {Name: "route", Status: "configured"},
552 {Name: "attachment:postgres:16", Status: "created"},554 {Name: "attachment:postgres:16", Status: "created"},
@@ -577,8 +579,11 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) {
577 579
578 for _, tt := range tests {580 for _, tt := range tests {
579 t.Run(tt.name, func(t *testing.T) {581 t.Run(tt.name, func(t *testing.T) {
582+ if tt.wantStatus == http.StatusOK || strings.Contains(tt.body, `"env"`) || strings.Contains(tt.body, `"attachments"`) {
583+ configSvc.EXPECT().GetRegistryDomain().Return("reg.bnema.dev").Once()
584+ }
580 if tt.wantStatus == http.StatusOK {585 if tt.wantStatus == http.StatusOK {
581- configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "myapp:latest"}).Return(nil).Once()586+ configSvc.EXPECT().AddRoute(mock.Anything, domain.Route{Domain: "app.example.com", Image: "reg.bnema.dev/myapp"}).Return(nil).Once()
582 }587 }
583 588
584 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))589 req := httptest.NewRequest("POST", "/admin/bootstrap", bytes.NewBufferString(tt.body))
M internal/usecase/config/service.go +32 -0
@@ -3,6 +3,7 @@ package config
33
44 import (
55 "context"
6+ "fmt"
67 "strings"
78 "sync"
89 "sync/atomic"
@@ -292,6 +293,37 @@ func NormalizeRegistryImage(imageName, registryDomain string) string {
292293 return imageName
293294 }
294295
296+// NormalizeBootstrapImage converts a user-supplied image argument into
297+// the canonical "registry/name" format expected by push.
298+// Bare names get the registry domain prepended; tags are stripped.
299+func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
300+ if image == "" {
301+ return "", fmt.Errorf("image is required")
302+ }
303+ if registryDomain == "" {
304+ return "", fmt.Errorf("registry domain is not configured")
305+ }
306+
307+ registryDomain = strings.TrimSuffix(registryDomain, "/")
308+
309+ if slashIdx := strings.LastIndex(image, "/"); slashIdx != -1 {
310+ nameTag := image[slashIdx+1:]
311+ if colonIdx := strings.LastIndex(nameTag, ":"); colonIdx != -1 {
312+ image = image[:slashIdx+1] + nameTag[:colonIdx]
313+ }
314+ } else {
315+ if colonIdx := strings.LastIndex(image, ":"); colonIdx != -1 {
316+ image = image[:colonIdx]
317+ }
318+ }
319+
320+ if !strings.Contains(image, "/") {
321+ image = registryDomain + "/" + image
322+ }
323+
324+ return image, nil
325+}
326+
295327 // AddRoute adds a new route to the configuration and persists it.
296328 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
297329 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -3,6 +3,7 @@ package config
3 3
4 import (4 import (
5 "context"5 "context"
6+ "fmt"
6 "strings"7 "strings"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
@@ -292,6 +293,37 @@ func NormalizeRegistryImage(imageName, registryDomain string) string {
292 return imageName293 return imageName
293 }294 }
294 295
296+// NormalizeBootstrapImage converts a user-supplied image argument into
297+// the canonical "registry/name" format expected by push.
298+// Bare names get the registry domain prepended; tags are stripped.
299+func NormalizeBootstrapImage(image, registryDomain string) (string, error) {
300+ if image == "" {
301+ return "", fmt.Errorf("image is required")
302+ }
303+ if registryDomain == "" {
304+ return "", fmt.Errorf("registry domain is not configured")
305+ }
306+
307+ registryDomain = strings.TrimSuffix(registryDomain, "/")
308+
309+ if slashIdx := strings.LastIndex(image, "/"); slashIdx != -1 {
310+ nameTag := image[slashIdx+1:]
311+ if colonIdx := strings.LastIndex(nameTag, ":"); colonIdx != -1 {
312+ image = image[:slashIdx+1] + nameTag[:colonIdx]
313+ }
314+ } else {
315+ if colonIdx := strings.LastIndex(image, ":"); colonIdx != -1 {
316+ image = image[:colonIdx]
317+ }
318+ }
319+
320+ if !strings.Contains(image, "/") {
321+ image = registryDomain + "/" + image
322+ }
323+
324+ return image, nil
325+}
326+
295 // AddRoute adds a new route to the configuration and persists it.327 // AddRoute adds a new route to the configuration and persists it.
296 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {328 func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
297 ctx = zerowrap.CtxWithFields(ctx, map[string]any{329 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
M internal/usecase/config/service_test.go +33 -0
@@ -943,6 +943,39 @@ func TestNormalizeRegistryImage(t *testing.T) {
943943 }
944944 }
945945
946+func TestNormalizeBootstrapImage(t *testing.T) {
947+ tests := []struct {
948+ name string
949+ image string
950+ registryDomain string
951+ expected string
952+ wantErr string
953+ }{
954+ {name: "bare name prepends registry", image: "pitlane", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
955+ {name: "bare name strips tag", image: "pitlane:v1", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
956+ {name: "qualified image unchanged", image: "reg.bnema.dev/pitlane", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
957+ {name: "qualified image strips tag", image: "reg.bnema.dev/pitlane:abc123", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
958+ {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.bnema.dev", expected: "other.registry.com/myapp"},
959+ {name: "empty image", image: "", registryDomain: "reg.bnema.dev", wantErr: "image is required"},
960+ {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"},
961+ }
962+
963+ for _, tt := range tests {
964+ t.Run(tt.name, func(t *testing.T) {
965+ result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain)
966+ if tt.wantErr != "" {
967+ require.Error(t, err)
968+ assert.Equal(t, tt.wantErr, err.Error())
969+ assert.Empty(t, result)
970+ return
971+ }
972+
973+ require.NoError(t, err)
974+ assert.Equal(t, tt.expected, result)
975+ })
976+ }
977+}
978+
946979 func TestService_FindRoutesByImage(t *testing.T) {
947980 t.Run("exact match with tag", func(t *testing.T) {
948981 v := viper.New()
@@ -943,6 +943,39 @@ func TestNormalizeRegistryImage(t *testing.T) {
943 }943 }
944 }944 }
945 945
946+func TestNormalizeBootstrapImage(t *testing.T) {
947+ tests := []struct {
948+ name string
949+ image string
950+ registryDomain string
951+ expected string
952+ wantErr string
953+ }{
954+ {name: "bare name prepends registry", image: "pitlane", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
955+ {name: "bare name strips tag", image: "pitlane:v1", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
956+ {name: "qualified image unchanged", image: "reg.bnema.dev/pitlane", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
957+ {name: "qualified image strips tag", image: "reg.bnema.dev/pitlane:abc123", registryDomain: "reg.bnema.dev", expected: "reg.bnema.dev/pitlane"},
958+ {name: "other registry strips tag", image: "other.registry.com/myapp:v2", registryDomain: "reg.bnema.dev", expected: "other.registry.com/myapp"},
959+ {name: "empty image", image: "", registryDomain: "reg.bnema.dev", wantErr: "image is required"},
960+ {name: "empty registry domain", image: "pitlane", registryDomain: "", wantErr: "registry domain is not configured"},
961+ }
962+
963+ for _, tt := range tests {
964+ t.Run(tt.name, func(t *testing.T) {
965+ result, err := NormalizeBootstrapImage(tt.image, tt.registryDomain)
966+ if tt.wantErr != "" {
967+ require.Error(t, err)
968+ assert.Equal(t, tt.wantErr, err.Error())
969+ assert.Empty(t, result)
970+ return
971+ }
972+
973+ require.NoError(t, err)
974+ assert.Equal(t, tt.expected, result)
975+ })
976+ }
977+}
978+
946 func TestService_FindRoutesByImage(t *testing.T) {979 func TestService_FindRoutesByImage(t *testing.T) {
947 t.Run("exact match with tag", func(t *testing.T) {980 t.Run("exact match with tag", func(t *testing.T) {
948 v := viper.New()981 v := viper.New()

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