Skip to content

Commit 2ba6e8f

Browse files
JAORMXclaude
andcommitted
Address PR review feedback: validator registry and map type
- Change identifying_properties from Struct to map<string, Value> for better TypeScript codegen (per Evan's feedback) - Implement ValidatorRegistry with AddValidator/RemoveValidator pattern for entity-type-specific validation - Update RepositoryValidator to new interface (no entity type param) - Add clarifying comments for transaction boundaries and child entity reconciliation in add_originating_entity.go - Update all tests and mocks for new patterns Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 4d73f07 commit 2ba6e8f

File tree

16 files changed

+816
-520
lines changed

16 files changed

+816
-520
lines changed

docs/docs/ref/proto.mdx

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/controlplane/handlers_entity_instances.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -247,26 +247,32 @@ func (s *Server) RegisterEntity(
247247
// parseIdentifyingProperties converts proto properties to Properties object
248248
func parseIdentifyingProperties(req *pb.RegisterEntityRequest) (*properties.Properties, error) {
249249
identifyingProps := req.GetIdentifyingProperties()
250-
if identifyingProps == nil {
250+
if len(identifyingProps) == 0 {
251251
return nil, errors.New("identifying_properties is required")
252252
}
253253

254254
// Validate total size to prevent resource exhaustion
255-
// Using proto.Size provides a better bound than counting properties,
256-
// as it accounts for arbitrarily large values in a Struct
255+
// We sum the proto.Size of each value since map itself isn't a proto message
257256
const maxProtoSize = 32 * 1024 // 32KB should be plenty for identifying properties
258-
if protoSize := proto.Size(identifyingProps); protoSize > maxProtoSize {
257+
var totalSize int
258+
for _, v := range identifyingProps {
259+
totalSize += proto.Size(v)
260+
}
261+
if totalSize > maxProtoSize {
259262
return nil, fmt.Errorf("identifying_properties too large: %d bytes, max %d bytes",
260-
protoSize, maxProtoSize)
263+
totalSize, maxProtoSize)
261264
}
262265

263-
propsMap := identifyingProps.AsMap()
264-
265-
// Validate property keys are reasonable length
266-
for key := range propsMap {
266+
// Convert map[string]*structpb.Value to map[string]any
267+
propsMap := make(map[string]any, len(identifyingProps))
268+
for key, value := range identifyingProps {
269+
// Validate property keys are reasonable length
267270
if len(key) > 200 {
268271
return nil, fmt.Errorf("property key too long: %d characters", len(key))
269272
}
273+
if value != nil {
274+
propsMap[key] = value.AsInterface()
275+
}
270276
}
271277

272278
return properties.NewProperties(propsMap), nil

internal/controlplane/handlers_entity_instances_test.go

Lines changed: 46 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"database/sql"
99
"errors"
10+
"strings"
1011
"testing"
1112

1213
"github.com/google/uuid"
@@ -27,6 +28,16 @@ import (
2728
"github.com/mindersec/minder/pkg/entities/properties"
2829
)
2930

31+
// toIdentifyingProps converts a map[string]any to map[string]*structpb.Value for tests
32+
func toIdentifyingProps(m map[string]any) map[string]*structpb.Value {
33+
result := make(map[string]*structpb.Value, len(m))
34+
for k, v := range m {
35+
val, _ := structpb.NewValue(v)
36+
result[k] = val
37+
}
38+
return result
39+
}
40+
3041
func TestServer_RegisterEntity(t *testing.T) {
3142
t.Parallel()
3243

@@ -50,13 +61,10 @@ func TestServer_RegisterEntity(t *testing.T) {
5061
request: &pb.RegisterEntityRequest{
5162
Context: &pb.ContextV2{},
5263
EntityType: pb.Entity_ENTITY_REPOSITORIES,
53-
IdentifyingProperties: func() *structpb.Struct {
54-
s, _ := structpb.NewStruct(map[string]any{
55-
"github/repo_owner": "test-owner",
56-
"github/repo_name": "test-repo",
57-
})
58-
return s
59-
}(),
64+
IdentifyingProperties: toIdentifyingProps(map[string]any{
65+
"github/repo_owner": "test-owner",
66+
"github/repo_name": "test-repo",
67+
}),
6068
},
6169
setupContext: func(ctx context.Context) context.Context {
6270
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -106,12 +114,9 @@ func TestServer_RegisterEntity(t *testing.T) {
106114
{
107115
name: "fails when entity_type is unspecified",
108116
request: &pb.RegisterEntityRequest{
109-
Context: &pb.ContextV2{},
110-
EntityType: pb.Entity_ENTITY_UNSPECIFIED,
111-
IdentifyingProperties: func() *structpb.Struct {
112-
s, _ := structpb.NewStruct(map[string]any{"key": "value"})
113-
return s
114-
}(),
117+
Context: &pb.ContextV2{},
118+
EntityType: pb.Entity_ENTITY_UNSPECIFIED,
119+
IdentifyingProperties: toIdentifyingProps(map[string]any{"key": "value"}),
115120
},
116121
setupContext: func(ctx context.Context) context.Context {
117122
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -145,12 +150,9 @@ func TestServer_RegisterEntity(t *testing.T) {
145150
{
146151
name: "fails when provider not found",
147152
request: &pb.RegisterEntityRequest{
148-
Context: &pb.ContextV2{},
149-
EntityType: pb.Entity_ENTITY_REPOSITORIES,
150-
IdentifyingProperties: func() *structpb.Struct {
151-
s, _ := structpb.NewStruct(map[string]any{"key": "value"})
152-
return s
153-
}(),
153+
Context: &pb.ContextV2{},
154+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
155+
IdentifyingProperties: toIdentifyingProps(map[string]any{"key": "value"}),
154156
},
155157
setupContext: func(ctx context.Context) context.Context {
156158
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -172,13 +174,10 @@ func TestServer_RegisterEntity(t *testing.T) {
172174
request: &pb.RegisterEntityRequest{
173175
Context: &pb.ContextV2{},
174176
EntityType: pb.Entity_ENTITY_REPOSITORIES,
175-
IdentifyingProperties: func() *structpb.Struct {
176-
s, _ := structpb.NewStruct(map[string]any{
177-
"github/repo_owner": "test-owner",
178-
"github/repo_name": "archived-repo",
179-
})
180-
return s
181-
}(),
177+
IdentifyingProperties: toIdentifyingProps(map[string]any{
178+
"github/repo_owner": "test-owner",
179+
"github/repo_name": "archived-repo",
180+
}),
182181
},
183182
setupContext: func(ctx context.Context) context.Context {
184183
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -209,13 +208,10 @@ func TestServer_RegisterEntity(t *testing.T) {
209208
request: &pb.RegisterEntityRequest{
210209
Context: &pb.ContextV2{},
211210
EntityType: pb.Entity_ENTITY_REPOSITORIES,
212-
IdentifyingProperties: func() *structpb.Struct {
213-
s, _ := structpb.NewStruct(map[string]any{
214-
"github/repo_owner": "test-owner",
215-
"github/repo_name": "private-repo",
216-
})
217-
return s
218-
}(),
211+
IdentifyingProperties: toIdentifyingProps(map[string]any{
212+
"github/repo_owner": "test-owner",
213+
"github/repo_name": "private-repo",
214+
}),
219215
},
220216
setupContext: func(ctx context.Context) context.Context {
221217
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -243,12 +239,9 @@ func TestServer_RegisterEntity(t *testing.T) {
243239
{
244240
name: "handles internal errors appropriately",
245241
request: &pb.RegisterEntityRequest{
246-
Context: &pb.ContextV2{},
247-
EntityType: pb.Entity_ENTITY_REPOSITORIES,
248-
IdentifyingProperties: func() *structpb.Struct {
249-
s, _ := structpb.NewStruct(map[string]any{"key": "value"})
250-
return s
251-
}(),
242+
Context: &pb.ContextV2{},
243+
EntityType: pb.Entity_ENTITY_REPOSITORIES,
244+
IdentifyingProperties: toIdentifyingProps(map[string]any{"key": "value"}),
252245
},
253246
setupContext: func(ctx context.Context) context.Context {
254247
return engcontext.WithEntityContext(ctx, &engcontext.EntityContext{
@@ -332,14 +325,11 @@ func TestParseIdentifyingProperties(t *testing.T) {
332325
{
333326
name: "parses valid properties",
334327
request: &pb.RegisterEntityRequest{
335-
IdentifyingProperties: func() *structpb.Struct {
336-
s, _ := structpb.NewStruct(map[string]any{
337-
"github/repo_owner": "stacklok",
338-
"github/repo_name": "minder",
339-
"upstream_id": "12345",
340-
})
341-
return s
342-
}(),
328+
IdentifyingProperties: toIdentifyingProps(map[string]any{
329+
"github/repo_owner": "stacklok",
330+
"github/repo_name": "minder",
331+
"upstream_id": "12345",
332+
}),
343333
},
344334
wantErr: false,
345335
validate: func(t *testing.T, props *properties.Properties) {
@@ -365,16 +355,12 @@ func TestParseIdentifyingProperties(t *testing.T) {
365355
{
366356
name: "rejects properties that are too large",
367357
request: &pb.RegisterEntityRequest{
368-
IdentifyingProperties: func() *structpb.Struct {
358+
IdentifyingProperties: func() map[string]*structpb.Value {
369359
// Create a value large enough to exceed 32KB limit
370-
largeValue := string(make([]byte, 33*1024))
371-
for i := range largeValue {
372-
largeValue = string(append([]byte(largeValue[:i]), 'x'))
373-
}
374-
s, _ := structpb.NewStruct(map[string]any{
360+
largeValue := strings.Repeat("x", 33*1024)
361+
return toIdentifyingProps(map[string]any{
375362
"large_key": largeValue,
376363
})
377-
return s
378364
}(),
379365
},
380366
wantErr: true,
@@ -383,15 +369,11 @@ func TestParseIdentifyingProperties(t *testing.T) {
383369
{
384370
name: "rejects property key that is too long",
385371
request: &pb.RegisterEntityRequest{
386-
IdentifyingProperties: func() *structpb.Struct {
387-
longKey := string(make([]byte, 201))
388-
for i := range longKey {
389-
longKey = string(append([]byte(longKey[:i]), 'a'))
390-
}
391-
s, _ := structpb.NewStruct(map[string]any{
372+
IdentifyingProperties: func() map[string]*structpb.Value {
373+
longKey := strings.Repeat("a", 201)
374+
return toIdentifyingProps(map[string]any{
392375
longKey: "value",
393376
})
394-
return s
395377
}(),
396378
},
397379
wantErr: true,
@@ -400,17 +382,10 @@ func TestParseIdentifyingProperties(t *testing.T) {
400382
{
401383
name: "handles empty properties map",
402384
request: &pb.RegisterEntityRequest{
403-
IdentifyingProperties: func() *structpb.Struct {
404-
s, _ := structpb.NewStruct(map[string]any{})
405-
return s
406-
}(),
407-
},
408-
wantErr: false,
409-
validate: func(t *testing.T, props *properties.Properties) {
410-
t.Helper()
411-
// Empty properties is valid (provider will validate)
412-
assert.NotNil(t, props)
385+
IdentifyingProperties: toIdentifyingProps(map[string]any{}),
413386
},
387+
wantErr: true, // Empty map is now an error (changed behavior)
388+
errContains: "identifying_properties is required",
414389
},
415390
}
416391

internal/entities/handlers/strategies/entity/add_originating_entity.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,18 +56,26 @@ func (a *addOriginatingEntityStrategy) GetEntity(
5656
}
5757

5858
// Get provider from DB
59+
// Note: These reads are outside the transaction boundary in EntityCreator.CreateEntity
60+
// because they read stable data (parent entity and provider configuration).
61+
// The transaction in CreateEntity protects the writes (entity + properties).
62+
// If there's a race where parent is deleted between read/write, the FK constraint catches it.
5963
provider, err := a.store.GetProviderByID(ctx, parentEwp.Entity.ProviderID)
6064
if err != nil {
6165
return nil, fmt.Errorf("error getting provider: %w", err)
6266
}
6367

6468
// Use EntityCreator to create child entity
69+
// Note: Child entities (artifacts, releases, PRs) don't trigger reconciliation events.
70+
// This matches existing behavior and avoids potential loops since this code runs
71+
// from a message handler. The parent repository's reconciliation handles the
72+
// evaluation of child entities through the entity evaluation graph.
6573
childEwp, err := a.entityCreator.CreateEntity(ctx, &provider,
6674
parentEwp.Entity.ProjectID, entMsg.Entity.Type, childProps,
6775
&entityService.EntityCreationOptions{
6876
OriginatingEntityID: &parentEwp.Entity.ID,
6977
RegisterWithProvider: false, // No webhooks for child entities
70-
PublishReconciliationEvent: false, // No reconciliation for child entities
78+
PublishReconciliationEvent: false, // Explained above
7179
})
7280
if err != nil {
7381
return nil, fmt.Errorf("error creating entity: %w", err)

internal/entities/service/entity_creator.go

Lines changed: 14 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/mindersec/minder/internal/engine/entities"
1717
"github.com/mindersec/minder/internal/entities/models"
1818
propService "github.com/mindersec/minder/internal/entities/properties/service"
19+
"github.com/mindersec/minder/internal/entities/service/validators"
1920
"github.com/mindersec/minder/internal/providers/manager"
2021
reconcilers "github.com/mindersec/minder/internal/reconcilers/messages"
2122
pb "github.com/mindersec/minder/pkg/api/protobuf/go/minder/v1"
@@ -52,23 +53,12 @@ type EntityCreator interface {
5253
) (*models.EntityWithProperties, error)
5354
}
5455

55-
// EntityValidator validates entity creation based on business rules
56-
type EntityValidator interface {
57-
// Validate returns nil if entity is valid, error otherwise
58-
Validate(
59-
ctx context.Context,
60-
entType pb.Entity,
61-
props *properties.Properties,
62-
projectID uuid.UUID,
63-
) error
64-
}
65-
6656
type entityCreator struct {
67-
store db.Store
68-
propSvc propService.PropertiesService
69-
providerManager manager.ProviderManager
70-
eventProducer interfaces.Publisher
71-
validators []EntityValidator
57+
store db.Store
58+
propSvc propService.PropertiesService
59+
providerManager manager.ProviderManager
60+
eventProducer interfaces.Publisher
61+
validatorRegistry validators.ValidatorRegistry
7262
}
7363

7464
// NewEntityCreator creates a new EntityCreator
@@ -77,14 +67,14 @@ func NewEntityCreator(
7767
propSvc propService.PropertiesService,
7868
providerManager manager.ProviderManager,
7969
eventProducer interfaces.Publisher,
80-
validators []EntityValidator,
70+
validatorRegistry validators.ValidatorRegistry,
8171
) EntityCreator {
8272
return &entityCreator{
83-
store: store,
84-
propSvc: propSvc,
85-
providerManager: providerManager,
86-
eventProducer: eventProducer,
87-
validators: validators,
73+
store: store,
74+
propSvc: propSvc,
75+
providerManager: providerManager,
76+
eventProducer: eventProducer,
77+
validatorRegistry: validatorRegistry,
8878
}
8979
}
9080

@@ -122,8 +112,8 @@ func (e *entityCreator) CreateEntity(
122112
return nil, fmt.Errorf("error fetching properties: %w", err)
123113
}
124114

125-
// 4. Run validators
126-
if err := e.runValidators(ctx, entityType, allProps, projectID); err != nil {
115+
// 4. Run validators via registry
116+
if err := e.validatorRegistry.Validate(ctx, entityType, allProps, projectID); err != nil {
127117
return nil, err
128118
}
129119

@@ -199,20 +189,6 @@ func (e *entityCreator) CreateEntity(
199189
return ewp, nil
200190
}
201191

202-
func (e *entityCreator) runValidators(
203-
ctx context.Context,
204-
entityType pb.Entity,
205-
allProps *properties.Properties,
206-
projectID uuid.UUID,
207-
) error {
208-
for _, validator := range e.validators {
209-
if err := validator.Validate(ctx, entityType, allProps, projectID); err != nil {
210-
return err
211-
}
212-
}
213-
return nil
214-
}
215-
216192
func (e *entityCreator) publishReconciliationEvent(
217193
_ context.Context,
218194
ewp *models.EntityWithProperties,

0 commit comments

Comments
 (0)