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
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() | ||