detached e618b8c

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

commit
e618b8c72b30ba1128a22beafa1a42d95c118dd3
author
brice · · 2026-03-14 04:29 UTC
parent
b477285
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