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 ( | |||
| 11 | 11 | "github.com/bnema/gordon/internal/app" | |
| 12 | 12 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | "github.com/bnema/gordon/internal/usecase/config" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | 17 | type localControlPlane struct { | |
| @@ -124,10 +125,19 @@ func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) | |||
| 124 | 125 | } | |
| 125 | 126 | ||
| 126 | 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 | 137 | resp := &dto.BootstrapResponse{ | |
| 128 | 138 | 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), | |
| 131 | 141 | } | |
| 132 | 142 | ||
| 133 | 143 | if l.configSvc == nil { | |
| @@ -142,7 +152,7 @@ func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequ | |||
| 142 | 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 | 156 | switch err { | |
| 147 | 157 | case nil: | |
| 148 | 158 | 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) { | |||
| 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 | 41 | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |
| 41 | 42 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 42 | 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 | 57 | var response dto.BootstrapResponse | |
| 57 | 58 | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 58 | 59 | 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) | |
| 61 | 62 | assert.Equal(t, []dto.BootstrapStep{ | |
| 62 | 63 | {Name: "route", Status: "configured"}, | |
| 63 | 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 | 90 | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(nil).Once() | |
| 89 | 91 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 90 | 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 | 95 | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(domain.ErrAttachmentExists).Once() | |
| 94 | 96 | secretSvc.EXPECT().Set(mock.Anything, payload.Domain, map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 95 | 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 | 111 | var response dto.BootstrapResponse | |
| 110 | 112 | require.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 111 | 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 | 115 | require.Len(t, response.Steps, 4) | |
| 114 | 116 | if i == 0 { | |
| 115 | 117 | assert.Equal(t, []dto.BootstrapStep{ | |
| @@ -171,6 +173,36 @@ func TestBootstrap_MissingImage(t *testing.T) { | |||
| 171 | 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 | 206 | func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |
| 175 | 207 | tests := []struct { | |
| 176 | 208 | name string | |
| @@ -196,7 +228,8 @@ func TestBootstrap_AddRouteValidationErrors(t *testing.T) { | |||
| 196 | 228 | handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil) | |
| 197 | 229 | ||
| 198 | 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 | 234 | body, err := json.Marshal(payload) | |
| 202 | 235 | require.NoError(t, err) | |
| @@ -227,7 +260,8 @@ func TestBootstrap_PartialFailure_AttachmentError(t *testing.T) { | |||
| 227 | 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 | 265 | configSvc.EXPECT().AddAttachment(mock.Anything, payload.Domain, "postgres:16").Return(errors.New("attachment failed")).Once() | |
| 232 | 266 | ||
| 233 | 267 | 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.BootstrapResponse | 57 | 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.BootstrapResponse | 111 | 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 string | 208 | 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 ( | |||
| 19 | 19 | "github.com/bnema/gordon/internal/boundaries/in" | |
| 20 | 20 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 21 | 21 | "github.com/bnema/gordon/internal/domain" | |
| 22 | + | "github.com/bnema/gordon/internal/usecase/config" | |
| 22 | 23 | "github.com/bnema/gordon/internal/usecase/registry" | |
| 23 | 24 | "github.com/bnema/gordon/pkg/validation" | |
| 24 | 25 | ) | |
| @@ -312,6 +313,12 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 312 | 313 | 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 | 322 | if err := h.validateBootstrapPermissions(ctx, req); err != nil { | |
| 316 | 323 | h.sendError(w, http.StatusForbidden, err.Error()) | |
| 317 | 324 | return | |
| @@ -319,14 +326,14 @@ func (h *Handler) handleBootstrap(w http.ResponseWriter, r *http.Request) { | |||
| 319 | 326 | ||
| 320 | 327 | resp := dto.BootstrapResponse{ | |
| 321 | 328 | 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), | |
| 324 | 331 | } | |
| 325 | 332 | addStep := func(name, status string) { | |
| 326 | 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 | 337 | switch { | |
| 331 | 338 | case err == nil: | |
| 332 | 339 | 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 | return | 313 | 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 | return | 324 | 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 ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "net/http" | |
| 10 | 10 | "net/http/httptest" | |
| 11 | + | "strings" | |
| 11 | 12 | "testing" | |
| 12 | 13 | "time" | |
| 13 | 14 | ||
| @@ -517,7 +518,8 @@ func TestHandler_Bootstrap_HappyPath(t *testing.T) { | |||
| 517 | 518 | ||
| 518 | 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 | 523 | configSvc.EXPECT().AddAttachment(mock.Anything, "app.example.com", "postgres:16").Return(nil).Once() | |
| 522 | 524 | secretSvc.EXPECT().Set(mock.Anything, "app.example.com", map[string]string{"APP_ENV": "prod"}).Return(nil).Once() | |
| 523 | 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 | 547 | var response dto.BootstrapResponse | |
| 546 | 548 | assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response)) | |
| 547 | 549 | 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) | |
| 550 | 552 | assert.Equal(t, []dto.BootstrapStep{ | |
| 551 | 553 | {Name: "route", Status: "configured"}, | |
| 552 | 554 | {Name: "attachment:postgres:16", Status: "created"}, | |
| @@ -577,8 +579,11 @@ func TestHandler_Bootstrap_RequiresPermissions(t *testing.T) { | |||
| 577 | 579 | ||
| 578 | 580 | for _, tt := range tests { | |
| 579 | 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 | 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 | 589 | 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.BootstrapResponse | 547 | 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 | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "fmt" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| @@ -292,6 +293,37 @@ func NormalizeRegistryImage(imageName, registryDomain string) string { | |||
| 292 | 293 | 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 | 327 | // AddRoute adds a new route to the configuration and persists it. | |
| 296 | 328 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |
| 297 | 329 | 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 imageName | 293 | 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) { | |||
| 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 | 979 | func TestService_FindRoutesByImage(t *testing.T) { | |
| 947 | 980 | t.Run("exact match with tag", func(t *testing.T) { | |
| 948 | 981 | 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() | ||