diff --git a/api/catalog/v1alpha1/openapi.yaml b/api/catalog/v1alpha1/openapi.yaml index 70e5e42..2ae484c 100644 --- a/api/catalog/v1alpha1/openapi.yaml +++ b/api/catalog/v1alpha1/openapi.yaml @@ -192,7 +192,7 @@ paths: type: string description: | Filter catalog items by service type. - Only returns items where spec.service_type matches this value. + Returns items where any resource's service_type matches. example: vm responses: @@ -301,7 +301,8 @@ paths: description: | Updates specific fields of a catalog item using JSON Merge Patch (RFC 7396). - Note that api_version and spec.service_type are immutable after creation. + Note that api_version and resource structure (resource names, + service types, requires_resources) are immutable after creation. parameters: - $ref: '#/components/parameters/CatalogItemIdPath' @@ -775,24 +776,62 @@ components: CatalogItemSpec: type: object description: | - Specification for a catalog item, defining the service type reference - and field configurations. + Specification for a catalog item. Every catalog item declares + one or more named resources (`resources`, min 1). A single-resource + offering uses `resources` with one entry. + required: + - resources + properties: + resources: + type: array + minItems: 1 + description: | + Named resources. Each entry declares a service type, optional + dependency ordering, and field configurations. + items: + $ref: '#/components/schemas/CatalogResource' + + CatalogResource: + type: object + description: | + A named resource within a catalog item. + required: + - name + - service_type properties: + name: + type: string + minLength: 1 + description: | + Stable identifier for this resource within the catalog item + (e.g., ordersDb, app). Used in user_values.resource and CEL + references such as ${ordersDb.connectionString}. + example: ordersDb + service_type: type: string minLength: 1 description: | - The Service type this catalog item references. - Immutable after creation. - example: vm + Service type for this resource + (vm, container, database, cluster). + example: database + + requires_resources: + type: array + description: | + Names of other catalog resources that must reach Ready state + before this resource is provisioned. + items: + type: string + minLength: 1 + example: + - ordersDb fields: type: array - minItems: 1 description: | - Array of field configurations for this catalog item. - Each configuration defines constraints and defaults for fields - in the service type specification. + Defaults and validation for this resource. Paths are relative + to the service type spec (e.g., engine, image.reference). items: $ref: '#/components/schemas/FieldConfiguration' @@ -982,15 +1021,23 @@ components: UserValue: type: object required: + - resource - path - value properties: + resource: + type: string + minLength: 1 + description: | + Resource name this value targets. + example: ordersDb + path: type: string description: | - JSON path to the user value in the CatalogItem spec using dot notation. - Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" - example: spec.vcpu.count + JSON path to the user value relative to the resource's service type + spec using dot notation. + example: version value: description: | diff --git a/api/catalog/v1alpha1/spec.gen.go b/api/catalog/v1alpha1/spec.gen.go index 9efc9f8..f5a49c1 100644 --- a/api/catalog/v1alpha1/spec.gen.go +++ b/api/catalog/v1alpha1/spec.gen.go @@ -20,101 +20,108 @@ import ( // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", - "i6a7ehvHZDtvgb5Z/F8AAAD//w==", + "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", + "XEVuSZniO6yJFE279w4OyU7Wk31z/38BAAD//w==", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/api/catalog/v1alpha1/types.gen.go b/api/catalog/v1alpha1/types.gen.go index 770d0c4..0b2c581 100644 --- a/api/catalog/v1alpha1/types.gen.go +++ b/api/catalog/v1alpha1/types.gen.go @@ -74,8 +74,9 @@ type CatalogItem struct { // Path Resource path in the format: catalog-items/{catalogItemId} Path *string `json:"path,omitempty"` - // Spec Specification for a catalog item, defining the service type reference - // and field configurations. + // Spec Specification for a catalog item. Every catalog item declares + // one or more named resources (`resources`, min 1). A single-resource + // offering uses `resources` with one entry. Spec *CatalogItemSpec `json:"spec,omitempty"` // Uid Unique identifier for the catalog item. This field is output-only and @@ -155,17 +156,33 @@ type CatalogItemList struct { Results []CatalogItem `json:"results"` } -// CatalogItemSpec Specification for a catalog item, defining the service type reference -// and field configurations. +// CatalogItemSpec Specification for a catalog item. Every catalog item declares +// one or more named resources (`resources`, min 1). A single-resource +// offering uses `resources` with one entry. type CatalogItemSpec struct { - // Fields Array of field configurations for this catalog item. - // Each configuration defines constraints and defaults for fields - // in the service type specification. + // Resources Named resources. Each entry declares a service type, optional + // dependency ordering, and field configurations. + Resources []CatalogResource `json:"resources"` +} + +// CatalogResource A named resource within a catalog item. +type CatalogResource struct { + // Fields Defaults and validation for this resource. Paths are relative + // to the service type spec (e.g., engine, image.reference). Fields *[]FieldConfiguration `json:"fields,omitempty"` - // ServiceType The Service type this catalog item references. - // Immutable after creation. - ServiceType *string `json:"service_type,omitempty"` + // Name Stable identifier for this resource within the catalog item + // (e.g., ordersDb, app). Used in user_values.resource and CEL + // references such as ${ordersDb.connectionString}. + Name string `json:"name"` + + // RequiresResources Names of other catalog resources that must reach Ready state + // before this resource is provisioned. + RequiresResources *[]string `json:"requires_resources,omitempty"` + + // ServiceType Service type for this resource + // (vm, container, database, cluster). + ServiceType string `json:"service_type"` } // Error Error response following RFC 7807 Problem Details for HTTP APIs @@ -302,10 +319,13 @@ type ServiceTypeList struct { // UserValue defines model for UserValue. type UserValue struct { - // Path JSON path to the user value in the CatalogItem spec using dot notation. - // Examples: "spec.vcpu.count", "spec.memory.size_gb", "metadata.labels.tier" + // Path JSON path to the user value relative to the resource's service type + // spec using dot notation. Path string `json:"path"` + // Resource Resource name this value targets. + Resource string `json:"resource"` + // Value Value for this user value. // Type depends on the field's schema (can be string, number, boolean, object, array). Value interface{} `json:"value"` @@ -388,7 +408,7 @@ type ListCatalogItemsParams struct { MaxPageSize *int32 `form:"max_page_size,omitempty" json:"max_page_size,omitempty"` // ServiceType Filter catalog items by service type. - // Only returns items where spec.service_type matches this value. + // Returns items where any resource's service_type matches. ServiceType *string `form:"service_type,omitempty" json:"service_type,omitempty"` } diff --git a/internal/catalog/handlers/v1alpha1/catalog_item.go b/internal/catalog/handlers/v1alpha1/catalog_item.go index 9e69fc2..c746f89 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item.go @@ -92,11 +92,13 @@ func validateAndBuildCreateCatalogItemRequest(request server.CreateCatalogItemRe if request.Body.Spec == nil { return nil, ErrEmptySpec } - if request.Body.Spec.ServiceType == nil { - return nil, ErrInvalidServiceType + if len(request.Body.Spec.Resources) == 0 { + return nil, ErrEmptyResources } - if request.Body.Spec.Fields == nil { - return nil, ErrEmptyFields + for _, r := range request.Body.Spec.Resources { + if r.ServiceType == "" { + return nil, ErrInvalidResourceServiceType + } } return &service.CreateCatalogItemRequest{ ID: request.Params.Id, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_errors.go b/internal/catalog/handlers/v1alpha1/catalog_item_errors.go index af46e52..0f90b83 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_errors.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_errors.go @@ -22,8 +22,11 @@ var ( // ErrEmptySpec indicates the spec is empty (must have at least one field) ErrEmptySpec = errors.New("spec cannot be empty: must have at least one field") - // ErrEmptyFields indicates the spec.fields array is empty (must have at least 1 field) - ErrEmptyFields = errors.New("spec.fields cannot be empty: must have at least one field") + // ErrEmptyResources indicates the spec.resources array is empty (must have at least 1 resource) + ErrEmptyResources = errors.New("spec.resources cannot be empty: must have at least one resource") + + // ErrInvalidResourceServiceType indicates a resource has an empty service_type + ErrInvalidResourceServiceType = errors.New("spec.resources[].service_type cannot be empty") ) // mapCreateCatalogItemErrorToHTTP converts service domain errors to CreateCatalogItem HTTP responses @@ -40,8 +43,12 @@ func mapCreateCatalogItemErrorToHTTP(err error) server.CreateCatalogItemResponse }, } case errors.Is(err, service.ErrServiceTypeNotFound), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrDependsOnCycleDetected), - errors.Is(err, service.ErrDependsOnPathNotFound): + errors.Is(err, service.ErrDependsOnPathNotFound), + errors.Is(err, service.ErrCatalogItemResourceNameTaken), + errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound), + errors.Is(err, service.ErrCatalogItemRequiresCycle): // Validation errors -> 400 Bad Request return server.CreateCatalogItem400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, @@ -91,9 +98,13 @@ func mapGetCatalogItemErrorToHTTP(err error) server.GetCatalogItemResponseObject // mapUpdateCatalogItemErrorToHTTP converts service domain errors to UpdateCatalogItem HTTP responses func mapUpdateCatalogItemErrorToHTTP(err error) server.UpdateCatalogItemResponseObject { switch { - case errors.Is(err, service.ErrImmutableFieldUpdate), + case errors.Is(err, service.ErrImmutableSpecStructureUpdate), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrDependsOnCycleDetected), - errors.Is(err, service.ErrDependsOnPathNotFound): + errors.Is(err, service.ErrDependsOnPathNotFound), + errors.Is(err, service.ErrCatalogItemResourceNameTaken), + errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound), + errors.Is(err, service.ErrCatalogItemRequiresCycle): // Validation errors -> 400 Bad Request return server.UpdateCatalogItem400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_instance.go b/internal/catalog/handlers/v1alpha1/catalog_item_instance.go index 1f2442a..c9f84df 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_instance.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_instance.go @@ -85,6 +85,12 @@ func validateAndBuildCreateCatalogItemInstanceRequest(request server.CreateCatal if request.Body.ApiVersion != supportedAPIVersion { return nil, ErrInvalidCatalogItemInstanceAPIVersion } + if request.Body.DisplayName == "" { + return nil, ErrInvalidCatalogItemInstanceDisplayName + } + if request.Body.Spec.CatalogItemId == "" { + return nil, ErrInvalidCatalogItemId + } return &service.CreateCatalogItemInstanceRequest{ ID: request.Params.Id, ApiVersion: request.Body.ApiVersion, diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go b/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go index fbb8704..a6e61a3 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_instance_errors.go @@ -33,10 +33,18 @@ func mapCreateCatalogItemInstanceErrorToHTTP(err error) server.CreateCatalogItem }, } case errors.Is(err, service.ErrCatalogItemNotFoundForInstance), + errors.Is(err, service.ErrCatalogItemSpecConflict), errors.Is(err, service.ErrUserValuePathNotFound), errors.Is(err, service.ErrUserValueNotEditable), errors.Is(err, service.ErrUserValueValidationFailed), - errors.Is(err, service.ErrUserValueDependsOnViolation): + errors.Is(err, service.ErrUserValueDependsOnViolation), + errors.Is(err, service.ErrUserValueResourceRequired), + errors.Is(err, service.ErrUserValueResourceNotFound), + errors.Is(err, service.ErrInvalidCELExpression), + errors.Is(err, service.ErrCELResourceNotFound), + errors.Is(err, service.ErrCELSelfReference), + errors.Is(err, service.ErrCELServiceTypeOutputNotFound), + errors.Is(err, service.ErrUserValueCELNotAllowed): return server.CreateCatalogItemInstance400JSONResponse(v1alpha1.Error{ Type: v1alpha1.INVALIDARGUMENT, Status: 400, @@ -112,6 +120,15 @@ func mapRehydrateCatalogItemInstanceErrorToHTTP(err error) server.RehydrateCatal Detail: stringPtr("this instance was modified by another request; please retry"), }, } + case errors.Is(err, service.ErrMultiResourceRehydrateNotSupported): + return server.RehydrateCatalogItemInstance500JSONResponse{ + InternalServerErrorJSONResponse: server.InternalServerErrorJSONResponse{ + Type: v1alpha1.UNIMPLEMENTED, + Status: 500, + Title: "Not Supported", + Detail: stringPtr(err.Error()), + }, + } case errors.Is(err, service.ErrPlacementManagerPolicyRejected): return server.RehydrateCatalogItemInstance406JSONResponse{ PolicyRejectedJSONResponse: server.PolicyRejectedJSONResponse{ diff --git a/internal/catalog/handlers/v1alpha1/catalog_item_test.go b/internal/catalog/handlers/v1alpha1/catalog_item_test.go index ce31a8c..17c6456 100644 --- a/internal/catalog/handlers/v1alpha1/catalog_item_test.go +++ b/internal/catalog/handlers/v1alpha1/catalog_item_test.go @@ -13,6 +13,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/api/server" v1alpha1 "github.com/dcm-project/control-plane/internal/catalog/handlers/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/service" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) // Mock CatalogItemService for testing @@ -82,17 +83,16 @@ func (m *mockCatalogItemServiceWrapper) Seed(_ context.Context) error { var _ = Describe("CatalogItem Handler", func() { var ( - ctx context.Context - handler *v1alpha1.Handler - mockCIService *mockCatalogItemService - mockSvc service.Service - testTime time.Time - testID string - testPath string - testApiVersion = "v1alpha1" - serviceTypeVM = "vm" - serviceTypeContainer = "container" - strintPtr = func(s string) *string { return &s } + ctx context.Context + handler *v1alpha1.Handler + mockCIService *mockCatalogItemService + mockSvc service.Service + testTime time.Time + testID string + testPath string + testApiVersion = "v1alpha1" + serviceTypeVM = "vm" + strintPtr = func(s string) *string { return &s } ) BeforeEach(func() { @@ -112,18 +112,15 @@ var _ = Describe("CatalogItem Handler", func() { mockCIService.createFunc = func(_ context.Context, req *service.CreateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { Expect(req.DisplayName).To(Equal(displayName)) Expect(req.ApiVersion).To(Equal("v1alpha1")) - Expect(*req.Spec.ServiceType).To(Equal(serviceTypeVM)) + Expect(req.Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) return &v1alpha1API.CatalogItem{ Uid: &testID, Path: &testPath, ApiVersion: &testApiVersion, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), CreateTime: &testTime, UpdateTime: &testTime, }, nil @@ -133,12 +130,9 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), }, } @@ -163,12 +157,9 @@ var _ = Describe("CatalogItem Handler", func() { Path: &path, ApiVersion: &testApiVersion, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, - CreateTime: &testTime, - UpdateTime: &testTime, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), + CreateTime: &testTime, + UpdateTime: &testTime, }, nil } @@ -177,10 +168,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -197,10 +185,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: nil, DisplayName: strintPtr("My Item"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -239,10 +224,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: nil, - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -277,15 +259,12 @@ var _ = Describe("CatalogItem Handler", func() { Expect(*badRequest.Detail).To(ContainSubstring("spec")) }) - It("should return 400 when spec.service_type is nil", func() { + It("should return 400 when spec.resources is empty", func() { request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr("My Item"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: nil, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: &v1alpha1API.CatalogItemSpec{Resources: nil}, }, } @@ -297,17 +276,20 @@ var _ = Describe("CatalogItem Handler", func() { Expect(badRequest.Status).To(Equal(int32(400))) Expect(badRequest.Type).To(Equal(v1alpha1API.INVALIDARGUMENT)) Expect(badRequest.Detail).ToNot(BeNil()) - Expect(*badRequest.Detail).To(ContainSubstring("spec.service_type")) + Expect(*badRequest.Detail).To(ContainSubstring("resources")) }) - It("should return 400 when spec.fields is nil", func() { + It("should return 400 when resource service_type is empty", func() { request := server.CreateCatalogItemRequestObject{ Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr("My Item"), Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: nil, + Resources: []v1alpha1API.CatalogResource{{ + Name: testutil.DefaultResourceName, + ServiceType: "", + Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, + }}, }, }, } @@ -320,7 +302,7 @@ var _ = Describe("CatalogItem Handler", func() { Expect(badRequest.Status).To(Equal(int32(400))) Expect(badRequest.Type).To(Equal(v1alpha1API.INVALIDARGUMENT)) Expect(badRequest.Detail).ToNot(BeNil()) - Expect(*badRequest.Detail).To(ContainSubstring("fields")) + Expect(*badRequest.Detail).To(ContainSubstring("service_type")) }) }) @@ -334,10 +316,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr("Duplicate"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -361,10 +340,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr("Test"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -390,10 +366,7 @@ var _ = Describe("CatalogItem Handler", func() { Body: &v1alpha1API.CatalogItem{ ApiVersion: &testApiVersion, DisplayName: strintPtr("Test"), - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, } @@ -419,7 +392,7 @@ var _ = Describe("CatalogItem Handler", func() { Path: &testPath, ApiVersion: &testApiVersion, DisplayName: strintPtr("Item 1"), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + Spec: testutil.PtrCatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, }, }, nil @@ -509,7 +482,7 @@ var _ = Describe("CatalogItem Handler", func() { Path: &testPath, ApiVersion: &testApiVersion, DisplayName: strintPtr("Test Item"), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + Spec: testutil.PtrCatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), CreateTime: &testTime, UpdateTime: &testTime, }, nil @@ -582,7 +555,7 @@ var _ = Describe("CatalogItem Handler", func() { Path: &testPath, ApiVersion: &testApiVersion, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + Spec: testutil.PtrCatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), UpdateTime: &testTime, }, nil } @@ -610,7 +583,7 @@ var _ = Describe("CatalogItem Handler", func() { return &v1alpha1API.CatalogItem{ Uid: &testID, DisplayName: strintPtr(displayName), - Spec: &v1alpha1API.CatalogItemSpec{ServiceType: &serviceTypeVM}, + Spec: testutil.PtrCatalogSpecVM([]v1alpha1API.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }, nil } @@ -630,16 +603,14 @@ var _ = Describe("CatalogItem Handler", func() { Context("with immutable field update attempt", func() { It("should return 400 for immutable field", func() { mockCIService.updateFunc = func(_ context.Context, _ string, _ *service.UpdateCatalogItemRequest) (*v1alpha1API.CatalogItem, error) { - return nil, service.ErrImmutableFieldUpdate + return nil, service.ErrImmutableSpecStructureUpdate } request := server.UpdateCatalogItemRequestObject{ CatalogItemId: testID, Body: &v1alpha1API.CatalogItem{ ApiVersion: strintPtr("v2beta1"), // Attempting to change immutable field - Spec: &v1alpha1API.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, // Attempting to change immutable field - }, + Spec: testutil.PtrCatalogSpec("container", nil), }, } diff --git a/internal/catalog/handlers/v1alpha1/handler.go b/internal/catalog/handlers/v1alpha1/handler.go index 7a79eba..ce337ea 100644 --- a/internal/catalog/handlers/v1alpha1/handler.go +++ b/internal/catalog/handlers/v1alpha1/handler.go @@ -42,7 +42,7 @@ var clientErrors = []error{ service.ErrCatalogItemNotFound, service.ErrCatalogItemIDTaken, service.ErrCatalogItemHasInstances, - service.ErrImmutableFieldUpdate, + service.ErrImmutableSpecStructureUpdate, service.ErrCatalogItemInstanceNotFound, service.ErrCatalogItemInstanceIDTaken, service.ErrCatalogItemNotFoundForInstance, @@ -51,7 +51,19 @@ var clientErrors = []error{ service.ErrUserValueValidationFailed, service.ErrDependsOnCycleDetected, service.ErrDependsOnPathNotFound, + service.ErrCatalogItemSpecConflict, + service.ErrCatalogItemResourceNameTaken, + service.ErrCatalogItemRequiresResourceNotFound, + service.ErrCatalogItemRequiresCycle, + service.ErrUserValueResourceRequired, + service.ErrUserValueResourceNotFound, + service.ErrMultiResourceRehydrateNotSupported, service.ErrUserValueDependsOnViolation, + service.ErrInvalidCELExpression, + service.ErrCELResourceNotFound, + service.ErrCELSelfReference, + service.ErrCELServiceTypeOutputNotFound, + service.ErrUserValueCELNotAllowed, service.ErrPlacementManagerPolicyRejected, service.ErrPlacementManagerProviderError, service.ErrPlacementManagerPolicyDependency, diff --git a/internal/catalog/service/catalog_item.go b/internal/catalog/service/catalog_item.go index b151d82..a394b53 100644 --- a/internal/catalog/service/catalog_item.go +++ b/internal/catalog/service/catalog_item.go @@ -15,7 +15,7 @@ type CreateCatalogItemRequest struct { ID *string // Optional user-specified ID ApiVersion string // e.g., "v1alpha1" DisplayName string // Required, max 63 chars - Spec v1alpha1.CatalogItemSpec // Required, contains service_type and fields + Spec v1alpha1.CatalogItemSpec // Required, contains catalog resources } // UpdateCatalogItemRequest contains the parameters for updating a catalog item @@ -95,9 +95,8 @@ func (s *catalogItemService) Create(ctx context.Context, req *CreateCatalogItemR // Convert to store model storeModel := catalogItemToStoreModel(id, path, req) - // Validate: no cyclic depends_on references among fields - if err := validateFieldDependsOnCycles(storeModel.Spec.Fields); err != nil { - s.logger.WarnContext(ctx, "Catalog item field depends_on validation failed", "id", id, "error", err) + if err := validateCatalogItemSpec(ctx, s.store, storeModel.Spec); err != nil { + s.logger.WarnContext(ctx, "Catalog item spec validation failed", "id", id, "error", err) return nil, err } @@ -142,9 +141,9 @@ func (s *catalogItemService) Update(ctx context.Context, id string, req *UpdateC return nil, err } - // Validate: no cyclic depends_on references among fields - if err := validateFieldDependsOnCycles(updated.Spec.Fields); err != nil { - s.logger.WarnContext(ctx, "Catalog item field depends_on validation failed on update", "id", id, "error", err) + // Validate spec after merge + if err := validateCatalogItemSpec(ctx, s.store, updated.Spec); err != nil { + s.logger.WarnContext(ctx, "Catalog item spec validation failed on update", "id", id, "error", err) return nil, err } @@ -176,83 +175,15 @@ func mergeCatalogItem(existing *model.CatalogItem, req *UpdateCatalogItemRequest // Validate and apply spec if provided if req.Spec != nil { - // Check immutability: spec.service_type cannot be changed - if req.Spec.ServiceType != nil && *req.Spec.ServiceType != existing.Spec.ServiceType { - return nil, ErrImmutableFieldUpdate - } - - var fields []model.FieldConfiguration - if req.Spec.Fields != nil { - // Convert API spec to model spec - fields = FieldConfigurationsToModel(*req.Spec.Fields) - } - merged.Spec = model.CatalogItemSpec{ - ServiceType: existing.Spec.ServiceType, - Fields: fields, + newSpec := catalogItemSpecAPIToModel(*req.Spec) + if err := validateCatalogImmutable(existing.Spec, newSpec); err != nil { + return nil, err } + merged.Spec = newSpec } return &merged, nil } -// validateFieldDependsOnCycles checks that every depends_on path references an existing -// field and that there are no cyclic depends_on references. It builds a directed graph -// (field path → depends_on path) and performs DFS-based cycle detection. -func validateFieldDependsOnCycles(fields []model.FieldConfiguration) error { - knownPaths := make(map[string]bool) - for _, f := range fields { - knownPaths[f.Path] = true - } - - // Build adjacency: field path → source path it depends on - edges := make(map[string]string) - for _, f := range fields { - if f.DependsOn != nil { - depPath := f.DependsOn.Path - if !knownPaths[depPath] { - return fmt.Errorf("%w: field %s depends_on path %q not found in fields", ErrDependsOnPathNotFound, f.Path, depPath) - } - edges[f.Path] = depPath - } - } - - if len(edges) == 0 { - return nil - } - - // DFS cycle detection - const ( - unvisited = 0 - visiting = 1 - visited = 2 - ) - state := make(map[string]int) - - var visit func(path string) error - visit = func(path string) error { - if state[path] == visited { - return nil - } - if state[path] == visiting { - return fmt.Errorf("%w: cycle involving %s", ErrDependsOnCycleDetected, path) - } - state[path] = visiting - if dep, ok := edges[path]; ok { - if err := visit(dep); err != nil { - return err - } - } - state[path] = visited - return nil - } - - for path := range edges { - if err := visit(path); err != nil { - return err - } - } - return nil -} - // Delete deletes a catalog item by ID func (s *catalogItemService) Delete(ctx context.Context, id string) error { err := s.store.CatalogItem().Delete(ctx, id) diff --git a/internal/catalog/service/catalog_item_converter.go b/internal/catalog/service/catalog_item_converter.go index 6c62008..e7fa4ea 100644 --- a/internal/catalog/service/catalog_item_converter.go +++ b/internal/catalog/service/catalog_item_converter.go @@ -10,43 +10,36 @@ import ( // catalogItemToStoreModel converts a CreateCatalogItemRequest to a store model func catalogItemToStoreModel(id, path string, req *CreateCatalogItemRequest) model.CatalogItem { - fields := FieldConfigurationsToModel(*req.Spec.Fields) + spec := catalogItemSpecAPIToModel(req.Spec) - storeModel := model.CatalogItem{ + return model.CatalogItem{ ID: id, ApiVersion: req.ApiVersion, DisplayName: req.DisplayName, - Spec: model.CatalogItemSpec{ - ServiceType: *req.Spec.ServiceType, - Fields: fields, - }, - Path: path, - SpecServiceType: *req.Spec.ServiceType, // Indexed field for filtering + Spec: spec, + Path: path, } - - return storeModel } // catalogItemToAPIType converts a store model to an API type func catalogItemToAPIType(m *model.CatalogItem) v1alpha1.CatalogItem { - fields := FieldConfigurationsFromModel(m.Spec.Fields) - apiType := v1alpha1.CatalogItem{ ApiVersion: &m.ApiVersion, DisplayName: &m.DisplayName, - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: &m.Spec.ServiceType, - Fields: &fields, - }, - Path: &m.Path, - Uid: &m.ID, - CreateTime: &m.CreateTime, - UpdateTime: &m.UpdateTime, + Spec: ptrCatalogItemSpec(catalogItemSpecModelToAPI(m.Spec)), + Path: &m.Path, + Uid: &m.ID, + CreateTime: &m.CreateTime, + UpdateTime: &m.UpdateTime, } return apiType } +func ptrCatalogItemSpec(spec v1alpha1.CatalogItemSpec) *v1alpha1.CatalogItemSpec { + return &spec +} + // mapCatalogItemStoreError converts store errors to service domain errors func mapCatalogItemStoreError(err error) error { if err == nil { diff --git a/internal/catalog/service/catalog_item_instance.go b/internal/catalog/service/catalog_item_instance.go index a3bcf4c..af27f01 100644 --- a/internal/catalog/service/catalog_item_instance.go +++ b/internal/catalog/service/catalog_item_instance.go @@ -100,34 +100,54 @@ func (s *catalogItemInstanceService) List(ctx context.Context, opts CatalogItemI func (s *catalogItemInstanceService) Create(ctx context.Context, req *CreateCatalogItemInstanceRequest) (*v1alpha1.CatalogItemInstance, error) { // Generate IDs id := getOrGenerateID(req.ID) - resourceID := uuid.New().String() - // Generate path path := fmt.Sprintf("catalog-item-instances/%s", id) - // Build resource spec (resolves reference chain and validates user_values) - resourceSpec, err := s.specBuilder.BuildResourceSpec(ctx, req.Spec.CatalogItemId, req.Spec.UserValues) + catalogItem, err := s.store.CatalogItem().Get(ctx, req.Spec.CatalogItemId) + if err != nil { + if errors.Is(err, store.ErrCatalogItemNotFound) { + return nil, ErrCatalogItemNotFoundForInstance + } + return nil, err + } + + if err := validateUserValuesForCatalogItem(catalogItem.Spec, req.Spec.UserValues); err != nil { + return nil, err + } + + return s.createInstance(ctx, id, path, req) +} + +func (s *catalogItemInstanceService) createInstance(ctx context.Context, id, path string, req *CreateCatalogItemInstanceRequest) (*v1alpha1.CatalogItemInstance, error) { + resolved, err := s.specBuilder.BuildResourceGraph(ctx, req.Spec.CatalogItemId, req.Spec.UserValues) if err != nil { - s.logger.WarnContext(ctx, "Failed to build resource spec", + s.logger.WarnContext(ctx, "Failed to build resource graph", "id", id, "catalog_item_id", req.Spec.CatalogItemId, "error", err, ) return nil, err } + if len(resolved) == 0 { + return nil, fmt.Errorf("%w: catalog item has no resources", ErrCatalogItemSpecConflict) + } - // DB first — fail fast on constraint violations (ID conflict, FK violation) - storeModel := catalogItemInstanceToStoreModel(id, resourceID, path, req) + resourceID := uuid.New().String() + storeModel := catalogItemInstanceToStoreModel(id, resourceID, path, nil, req) createdModel, err := s.store.CatalogItemInstance().Create(ctx, storeModel) if err != nil { s.logger.ErrorContext(ctx, "Failed to create catalog item instance in store", "id", id, "error", err) return nil, mapCatalogItemInstanceStoreError(err) } - // Call Placement Manager — only after DB validation passes - s.logger.DebugContext(ctx, "Calling placement manager to create resource", "id", id) + // Call Placement Manager with the first resolved resource until multi-resource placement is wired. + primary := resolved[0] + s.logger.DebugContext(ctx, "Calling placement manager to create resource", + "id", id, + "resource_name", primary.Name, + ) _, err = s.pmClient.CreateResource(ctx, placement.CreateResourceRequest{ CatalogItemInstanceID: id, - Spec: resourceSpec, + Spec: primary.Spec, }, resourceID) if err != nil { mapped := mapPlacementError(err, ErrPlacementManagerCreateFailed) @@ -146,8 +166,11 @@ func (s *catalogItemInstanceService) Create(ctx context.Context, req *CreateCata return nil, mapped } - s.logger.InfoContext(ctx, "Catalog item instance created", "id", id, "catalog_item_id", req.Spec.CatalogItemId) - // Convert result back to API type + s.logger.InfoContext(ctx, "Catalog item instance created", + "id", id, + "catalog_item_id", req.Spec.CatalogItemId, + "resource_count", len(resolved), + ) apiType := catalogItemInstanceToAPIType(createdModel) return &apiType, nil } @@ -176,6 +199,14 @@ func (s *catalogItemInstanceService) Rehydrate(ctx context.Context, id string) ( return nil, mapCatalogItemInstanceStoreError(err) } + catalogItem, err := s.store.CatalogItem().Get(ctx, instance.Spec.CatalogItemId) + if err != nil { + return nil, mapCatalogItemStoreError(err) + } + if catalogItem.Spec.IsMultiResource() { + return nil, ErrMultiResourceRehydrateNotSupported + } + oldResourceID := instance.ResourceID // Generate new resource ID newResourceID := uuid.New().String() @@ -225,14 +256,21 @@ func (s *catalogItemInstanceService) Rehydrate(ctx context.Context, id string) ( // Delete deletes a catalog item instance by ID func (s *catalogItemInstanceService) Delete(ctx context.Context, id string) error { - // Fetch instance for 404 handling and to get the resource ID instance, err := s.store.CatalogItemInstance().Get(ctx, id) if err != nil { return mapCatalogItemInstanceStoreError(err) } - // Delete PM resource using the stored resource ID - if instance.ResourceID != "" { + catalogItem, err := s.store.CatalogItem().Get(ctx, instance.Spec.CatalogItemId) + if err != nil { + return mapCatalogItemStoreError(err) + } + + if catalogItem.Spec.IsMultiResource() { + resourceIDs := instance.Spec.ResourceIDs + // batch delete resource ids in placement (resourceIDs) + _ = resourceIDs + } else if instance.ResourceID != "" { s.logger.DebugContext(ctx, "Calling placement manager to delete resource", "id", id, "resource_id", instance.ResourceID) if err := s.pmClient.DeleteResource(ctx, instance.ResourceID); err != nil { s.logger.ErrorContext(ctx, "Placement manager delete failed", "id", id, "error", err) @@ -240,7 +278,6 @@ func (s *catalogItemInstanceService) Delete(ctx context.Context, id string) erro } } - // Delete local record err = s.store.CatalogItemInstance().Delete(ctx, id) if err != nil { s.logger.ErrorContext(ctx, "Failed to delete catalog item instance from store", "id", id, "error", err) @@ -251,6 +288,8 @@ func (s *catalogItemInstanceService) Delete(ctx context.Context, id string) erro return nil } +// rollbackCatalogItemInstanceCreate deletes a catalog item instance after a failed +// placement create. Used with the DB-first create path so PM failures do not leave orphans. func (s *catalogItemInstanceService) rollbackCatalogItemInstanceCreate(id string) error { rbCtx, cancel := context.WithTimeout(context.Background(), catalogItemInstanceRollbackTimeout) defer cancel() diff --git a/internal/catalog/service/catalog_item_instance_converter.go b/internal/catalog/service/catalog_item_instance_converter.go index 04084a5..8aa9ba3 100644 --- a/internal/catalog/service/catalog_item_instance_converter.go +++ b/internal/catalog/service/catalog_item_instance_converter.go @@ -9,37 +9,52 @@ import ( ) // catalogItemInstanceToStoreModel converts a CreateCatalogItemInstanceRequest to a store model -func catalogItemInstanceToStoreModel(id, resourceID, path string, req *CreateCatalogItemInstanceRequest) model.CatalogItemInstance { +func catalogItemInstanceToStoreModel(id, resourceID, path string, resourceIDs []string, req *CreateCatalogItemInstanceRequest) model.CatalogItemInstance { userValues := make([]model.UserValue, len(req.Spec.UserValues)) for i, uv := range req.Spec.UserValues { - userValues[i] = model.UserValue{ - Path: uv.Path, - Value: uv.Value, - } + userValues[i] = userValueAPIToModel(uv) + } + + spec := model.CatalogItemInstanceSpec{ + CatalogItemId: req.Spec.CatalogItemId, + UserValues: userValues, + } + if len(resourceIDs) > 0 { + spec.ResourceIDs = append([]string(nil), resourceIDs...) } return model.CatalogItemInstance{ - ID: id, - ApiVersion: req.ApiVersion, - DisplayName: req.DisplayName, - Spec: model.CatalogItemInstanceSpec{ - CatalogItemId: req.Spec.CatalogItemId, - UserValues: userValues, - }, + ID: id, + ApiVersion: req.ApiVersion, + DisplayName: req.DisplayName, + Spec: spec, ResourceID: resourceID, Path: path, SpecCatalogItemId: req.Spec.CatalogItemId, } } +func userValueAPIToModel(uv v1alpha1.UserValue) model.UserValue { + return model.UserValue{ + Resource: uv.Resource, + Path: uv.Path, + Value: uv.Value, + } +} + +func userValueModelToAPI(uv model.UserValue) v1alpha1.UserValue { + return v1alpha1.UserValue{ + Resource: uv.Resource, + Path: uv.Path, + Value: uv.Value, + } +} + // catalogItemInstanceToAPIType converts a store model to an API type func catalogItemInstanceToAPIType(m *model.CatalogItemInstance) v1alpha1.CatalogItemInstance { userValues := make([]v1alpha1.UserValue, len(m.Spec.UserValues)) for i, uv := range m.Spec.UserValues { - userValues[i] = v1alpha1.UserValue{ - Path: uv.Path, - Value: uv.Value, - } + userValues[i] = userValueModelToAPI(uv) } apiType := v1alpha1.CatalogItemInstance{ diff --git a/internal/catalog/service/catalog_item_instance_test.go b/internal/catalog/service/catalog_item_instance_test.go index 9175345..1cb1b01 100644 --- a/internal/catalog/service/catalog_item_instance_test.go +++ b/internal/catalog/service/catalog_item_instance_test.go @@ -18,6 +18,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) // mockPMClient is a mock Placement Manager client for testing @@ -54,17 +55,31 @@ func (m *mockPMClient) RehydrateResource(ctx context.Context, resourceID string, return &placement.Resource{ID: newResourceID}, nil } +func seedCatalogItemInstance(ctx context.Context, str store.Store, id, resourceID string) { + _, err := str.CatalogItemInstance().Create(ctx, model.CatalogItemInstance{ + ID: id, + ApiVersion: "v1alpha1", + DisplayName: "Seeded instance", + Spec: model.CatalogItemInstanceSpec{ + CatalogItemId: "small-vm", + UserValues: []model.UserValue{}, + }, + ResourceID: resourceID, + Path: fmt.Sprintf("catalog-item-instances/%s", id), + SpecCatalogItemId: "small-vm", + }) + if err != nil { + panic(err) + } +} + func ensureCatalogItem(ctx context.Context, str store.Store, id, serviceType string) { ci := model.CatalogItem{ ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/%s", id), - SpecServiceType: serviceType, + Spec: testutil.ModelCatalogSpec(serviceType, []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := str.CatalogItem().Create(ctx, ci) if err != nil { @@ -77,12 +92,8 @@ func ensureCatalogItemWithFields(ctx context.Context, str store.Store, id, servi ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: fields, - }, - Path: fmt.Sprintf("catalog-items/%s", id), - SpecServiceType: serviceType, + Spec: testutil.ModelCatalogSpec(serviceType, fields), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := str.CatalogItem().Create(ctx, ci) if err != nil { @@ -104,6 +115,22 @@ func ensureServiceTypeWithSpec(ctx context.Context, str store.Store, id, service } } +func ensureMultiResourceCatalogItem(ctx context.Context, str store.Store, id string, resources []model.CatalogResource) { + ci := model.CatalogItem{ + ID: id, + ApiVersion: "v1alpha1", + DisplayName: fmt.Sprintf("Test %s", id), + Spec: model.CatalogItemSpec{ + Resources: resources, + }, + Path: fmt.Sprintf("catalog-items/%s", id), + } + _, err := str.CatalogItem().Create(ctx, ci) + if err != nil { + return + } +} + var _ = Describe("CatalogItemInstance Service", func() { var ( ctx context.Context @@ -170,6 +197,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(*result.Path).To(Equal("catalog-item-instances/my-instance")) Expect(result.ResourceId).ToNot(BeNil()) Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(mockPM.createCalls).To(Equal(1)) }) }) @@ -190,6 +218,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(*result.Uid).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) Expect(result.ResourceId).ToNot(BeNil()) Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + Expect(mockPM.createCalls).To(Equal(1)) Expect(*result.ResourceId).ToNot(Equal(*result.Uid)) }) }) @@ -223,7 +252,6 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result).To(BeNil()) // Make sure create was called only once (for the first request) Expect(mockPM.createCalls).To(Equal(1)) - // Make sure delete was not called (since the second request fast-failed) Expect(mockPM.deleteCalls).To(Equal(0)) }) }) @@ -259,7 +287,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-with-fields", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -280,7 +308,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-no-disk", UserValues: []v1alpha1.UserValue{ - {Path: "spec.disk.size", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size", Value: float64(100)}, }, }, } @@ -304,7 +332,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-immutable", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, }, }, } @@ -337,7 +365,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-validated", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, }, }, } @@ -370,7 +398,7 @@ var _ = Describe("CatalogItemInstance Service", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "vm-valid-schema", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -400,6 +428,150 @@ var _ = Describe("CatalogItemInstance Service", func() { Expect(result).ToNot(BeNil()) }) }) + + Context("multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceTypeWithSpec(ctx, str, "db-st", "database", map[string]any{ + "engine": "postgres", + "version": "14", + }) + ensureMultiResourceCatalogItem(ctx, str, "dev-app", []model.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres", Editable: true}, + {Path: "version", Default: "16", Editable: true}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "image", Default: "registry.example.com/app:1.0"}, + }, + }, + }) + }) + + It("should call PM with the first resolved resource", func() { + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App Instance", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.CatalogItemId).To(Equal("dev-app")) + Expect(mockPM.createCalls).To(Equal(1)) + Expect(result.ResourceId).ToNot(BeNil()) + Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) + }) + + It("should accept user_values with resource and path", func() { + resource := "ordersDb" + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App Override", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(mockPM.createCalls).To(Equal(1)) + }) + + It("should reject user_value without resource", func() { + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Missing resource", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(Equal(service.ErrUserValueResourceRequired)) + Expect(result).To(BeNil()) + Expect(mockPM.createCalls).To(Equal(0)) + }) + + It("should reject user_value for unknown resource", func() { + resource := "unknown" + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad resource", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }, + }, + } + + result, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("user value resource not found")) + Expect(result).To(BeNil()) + Expect(mockPM.createCalls).To(Equal(0)) + }) + + It("should delete local record without calling placement (batch delete is not wired yet)", func() { + instanceID := "multi-resource-delete" + _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ + ID: &instanceID, + ApiVersion: "v1alpha1", + DisplayName: "Multi-resource Delete", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + mockPM.deleteCalls = 0 + + err = svc.CatalogItemInstance().Delete(ctx, instanceID) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPM.deleteCalls).To(Equal(0)) + + _, err = svc.CatalogItemInstance().Get(ctx, instanceID) + Expect(err).To(Equal(service.ErrCatalogItemInstanceNotFound)) + }) + + It("should reject rehydrate for multi-resource instances", func() { + instanceID := "multi-resource-rehydrate" + _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ + ID: &instanceID, + ApiVersion: "v1alpha1", + DisplayName: "Multi-resource Rehydrate", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: "dev-app", + UserValues: []v1alpha1.UserValue{}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) + Expect(err).To(Equal(service.ErrMultiResourceRehydrateNotSupported)) + Expect(result).To(BeNil()) + Expect(mockPM.rehydrateCalls).To(Equal(0)) + }) + }) }) Describe("List", func() { @@ -606,20 +778,12 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { }) Describe("Create with PM", func() { - It("should call PM with separate resource ID and store it", func() { - var capturedReq placement.CreateResourceRequest - var capturedID string - mockPM.createFunc = func(_ context.Context, req placement.CreateResourceRequest, id string) (*placement.Resource, error) { - capturedReq = req - capturedID = id - return &placement.Resource{ID: id}, nil - } - - instanceID := "my-pm-instance" + It("should persist instance and call PM with the first resolved resource", func() { + instanceID := "graph-pending-instance" req := &service.CreateCatalogItemInstanceRequest{ ID: &instanceID, ApiVersion: "v1alpha1", - DisplayName: "PM Test Instance", + DisplayName: "Graph Pending", Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "small-vm", UserValues: []v1alpha1.UserValue{}, @@ -629,150 +793,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { result, err := svc.CatalogItemInstance().Create(ctx, req) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) - Expect(capturedReq.CatalogItemInstanceID).To(Equal(instanceID)) - // Resource ID passed to PM should be a UUID, different from instance ID - Expect(capturedID).ToNot(Equal(instanceID)) - Expect(capturedID).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - Expect(capturedReq.Spec).ToNot(BeNil()) - // Resource ID should be stored and returned in the API response + Expect(mockPM.createCalls).To(Equal(1)) Expect(result.ResourceId).ToNot(BeNil()) - Expect(*result.ResourceId).To(Equal(capturedID)) + Expect(*result.ResourceId).To(MatchRegexp(`^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$`)) - // Verify the resource ID is stored and returned in the API response got, err := svc.CatalogItemInstance().Get(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - Expect(got).ToNot(BeNil()) - Expect(*got.ResourceId).To(Equal(capturedID)) - }) - - It("should delete DB record when PM create fails with canceled request context", func() { - reqCtx, cancelReq := context.WithCancel(ctx) - mockPM.createFunc = func(context.Context, placement.CreateResourceRequest, string) (*placement.Resource, error) { - cancelReq() - return nil, context.Canceled - } - - instanceID := "pm-ctx-cancel-rollback" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Context Cancel", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(reqCtx, req) - Expect(err).To(HaveOccurred()) - Expect(result).To(BeNil()) - - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should delete DB record when PM create fails", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, errors.New("PM unavailable") - } - - instanceID := "pm-fail-instance" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Fail Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("placement manager create resource failed")) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerPolicyRejected when PM create returns 406", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 406, Body: "policy rejected"} - } - - instanceID := "pm-policy-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Policy Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerPolicyRejected)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerProviderError when PM create returns 422", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 422, Body: "provider error"} - } - - instanceID := "pm-provider-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Provider Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerProviderError)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) - }) - - It("should return ErrPlacementManagerPolicyDependency when PM create returns 424", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return nil, &placement.PlacementError{StatusCode: 424, Body: "policy dependency"} - } - - instanceID := "pm-dependency-fail" - req := &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Dependency Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - } - - result, err := svc.CatalogItemInstance().Create(ctx, req) - Expect(err).To(HaveOccurred()) - Expect(errors.Is(err, service.ErrPlacementManagerPolicyDependency)).To(BeTrue()) - Expect(result).To(BeNil()) - - // Verify DB record was cleaned up (rollback) - _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) - Expect(getErr).To(Equal(service.ErrCatalogItemInstanceNotFound)) + Expect(*got.ResourceId).To(Equal(*result.ResourceId)) }) }) @@ -787,17 +814,8 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { } instanceID := "rehydrate-instance" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "Rehydrate Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-before-rehydrate" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) @@ -830,28 +848,20 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { It("should rollback resource_id and not call PM when a second rehydrate races", func() { instanceID := "rehydrate-conflict" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "Conflict Test", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + oldResourceID := "resource-conflict-old" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) // First rehydrate succeeds result, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) newResourceID := *result.ResourceId - Expect(newResourceID).ToNot(Equal(*created.ResourceId)) + Expect(newResourceID).ToNot(Equal(oldResourceID)) // Simulate a concurrent caller that read the old resource_id before // the first rehydrate committed — manually revert DB to old resource_id // to set up the CAS conflict scenario directStore := str.CatalogItemInstance() - _, err = directStore.UpdateResourceID(ctx, instanceID, newResourceID, *created.ResourceId) + _, err = directStore.UpdateResourceID(ctx, instanceID, newResourceID, oldResourceID) Expect(err).ToNot(HaveOccurred()) // Now rehydrate again — this should succeed since resource_id matches @@ -859,22 +869,13 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { result2, err := svc.CatalogItemInstance().Rehydrate(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) Expect(mockPM.rehydrateCalls).To(Equal(1)) - Expect(*result2.ResourceId).ToNot(Equal(*created.ResourceId)) + Expect(*result2.ResourceId).ToNot(Equal(oldResourceID)) }) It("should rollback resource_id when PM rehydrate fails", func() { instanceID := "rehydrate-pm-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-pm-fail" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, errors.New("PM rehydrate unavailable") @@ -893,17 +894,8 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { It("should return ErrPlacementManagerPolicyRejected when PM rehydrate returns 406", func() { instanceID := "rehydrate-policy-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Policy Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-policy-fail" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 406, Body: "policy rejected"} @@ -922,17 +914,8 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { It("should return ErrPlacementManagerProviderError when PM rehydrate returns 422", func() { instanceID := "rehydrate-provider-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Provider Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-provider-fail" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 422, Body: "provider error"} @@ -951,17 +934,8 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { It("should return ErrPlacementManagerPolicyDependency when PM rehydrate returns 424", func() { instanceID := "rehydrate-dependency-fail" - created, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Rehydrate Dependency Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) - oldResourceID := *created.ResourceId + oldResourceID := "resource-rehydrate-dependency-fail" + seedCatalogItemInstance(ctx, str, instanceID, oldResourceID) mockPM.rehydrateFunc = func(_ context.Context, _ string, _ string) (*placement.Resource, error) { return nil, &placement.PlacementError{StatusCode: 424, Body: "policy dependency"} @@ -981,34 +955,19 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { Describe("Delete with PM", func() { It("should delete PM resource using stored resource ID then local record", func() { - var createdResourceID string var deletedResourceID string - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, id string) (*placement.Resource, error) { - createdResourceID = id - return &placement.Resource{ID: id}, nil - } + storedResourceID := "resource-delete-pm" mockPM.deleteFunc = func(_ context.Context, resourceID string) error { deletedResourceID = resourceID return nil } instanceID := "delete-pm-instance" - _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "To Delete", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + seedCatalogItemInstance(ctx, str, instanceID, storedResourceID) - err = svc.CatalogItemInstance().Delete(ctx, instanceID) + err := svc.CatalogItemInstance().Delete(ctx, instanceID) Expect(err).ToNot(HaveOccurred()) - // Delete should use the stored resource ID, not the instance ID - Expect(deletedResourceID).ToNot(Equal(instanceID)) - Expect(deletedResourceID).To(Equal(createdResourceID)) + Expect(deletedResourceID).To(Equal(storedResourceID)) // Verify local record deleted _, getErr := svc.CatalogItemInstance().Get(ctx, instanceID) @@ -1016,28 +975,15 @@ var _ = Describe("CatalogItemInstance Service with Placement Manager", func() { }) It("should not delete local record when PM delete fails", func() { - mockPM.createFunc = func(_ context.Context, _ placement.CreateResourceRequest, _ string) (*placement.Resource, error) { - return &placement.Resource{ID: "pm-fail-delete"}, nil - } - instanceID := "pm-delete-fail" - _, err := svc.CatalogItemInstance().Create(ctx, &service.CreateCatalogItemInstanceRequest{ - ID: &instanceID, - ApiVersion: "v1alpha1", - DisplayName: "PM Delete Fail", - Spec: v1alpha1.CatalogItemInstanceSpec{ - CatalogItemId: "small-vm", - UserValues: []v1alpha1.UserValue{}, - }, - }) - Expect(err).ToNot(HaveOccurred()) + seedCatalogItemInstance(ctx, str, instanceID, "resource-pm-delete-fail") // Make PM delete fail mockPM.deleteFunc = func(_ context.Context, _ string) error { return errors.New("PM delete unavailable") } - err = svc.CatalogItemInstance().Delete(ctx, instanceID) + err := svc.CatalogItemInstance().Delete(ctx, instanceID) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("placement manager delete resource failed")) diff --git a/internal/catalog/service/catalog_item_spec.go b/internal/catalog/service/catalog_item_spec.go new file mode 100644 index 0000000..72cadc5 --- /dev/null +++ b/internal/catalog/service/catalog_item_spec.go @@ -0,0 +1,54 @@ +package service + +import ( + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +func catalogItemSpecAPIToModel(spec v1alpha1.CatalogItemSpec) model.CatalogItemSpec { + return model.CatalogItemSpec{ + Resources: catalogResourceAPIToModel(spec.Resources), + } +} + +func catalogItemSpecModelToAPI(spec model.CatalogItemSpec) v1alpha1.CatalogItemSpec { + return v1alpha1.CatalogItemSpec{ + Resources: catalogResourceModelToAPI(spec.Resources), + } +} + +func catalogResourceAPIToModel(resources []v1alpha1.CatalogResource) []model.CatalogResource { + out := make([]model.CatalogResource, len(resources)) + for i, r := range resources { + out[i] = model.CatalogResource{ + Name: r.Name, + ServiceType: r.ServiceType, + } + if r.RequiresResources != nil { + out[i].RequiresResources = append([]string(nil), *r.RequiresResources...) + } + if r.Fields != nil { + out[i].Fields = FieldConfigurationsToModel(*r.Fields) + } + } + return out +} + +func catalogResourceModelToAPI(resources []model.CatalogResource) []v1alpha1.CatalogResource { + out := make([]v1alpha1.CatalogResource, len(resources)) + for i, r := range resources { + out[i] = v1alpha1.CatalogResource{ + Name: r.Name, + ServiceType: r.ServiceType, + } + if len(r.RequiresResources) > 0 { + req := append([]string(nil), r.RequiresResources...) + out[i].RequiresResources = &req + } + if len(r.Fields) > 0 { + fields := FieldConfigurationsFromModel(r.Fields) + out[i].Fields = &fields + } + } + return out +} diff --git a/internal/catalog/service/catalog_item_test.go b/internal/catalog/service/catalog_item_test.go index 4ea44f4..fd48bb6 100644 --- a/internal/catalog/service/catalog_item_test.go +++ b/internal/catalog/service/catalog_item_test.go @@ -17,6 +17,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) func ensureServiceType(ctx context.Context, str store.Store, id, serviceType string) { @@ -34,14 +35,37 @@ func ensureServiceType(ctx context.Context, str store.Store, id, serviceType str } } +func devAppCatalogItemSpec() v1alpha1.CatalogItemSpec { + requiresOrdersDb := []string{"ordersDb"} + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + {Path: "version", Default: "16"}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: &requiresOrdersDb, + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "image", Default: "registry.example.com/app:1.0"}, + }, + }, + }, + } +} + var _ = Describe("CatalogItem Service", func() { var ( - ctx context.Context - db *gorm.DB - str store.Store - svc service.Service - serviceTypeVM = "vm" - serviceTypeContainer = "container" + ctx context.Context + db *gorm.DB + str store.Store + svc service.Service + serviceTypeVM = "vm" ) BeforeEach(func() { @@ -78,12 +102,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &userID, ApiVersion: "v1alpha1", DisplayName: displayName, - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -91,8 +112,8 @@ var _ = Describe("CatalogItem Service", func() { Expect(result).ToNot(BeNil()) Expect(*result.Uid).To(Equal(userID)) Expect(*result.DisplayName).To(Equal(displayName)) - Expect(*result.Spec.ServiceType).To(Equal(serviceTypeVM)) - Expect(*result.Spec.Fields).To(HaveLen(1)) + Expect(result.Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) + Expect(*result.Spec.Resources[0].Fields).To(HaveLen(1)) }) }) @@ -101,12 +122,9 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Auto ID Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - }, + Spec: testutil.CatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -123,12 +141,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "First", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 2}, + }), } _, err := svc.CatalogItem().Create(ctx, req1) Expect(err).ToNot(HaveOccurred()) @@ -137,12 +152,9 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Second", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - }, + Spec: testutil.CatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }), } result, err := svc.CatalogItem().Create(ctx, req2) Expect(err).To(Equal(service.ErrCatalogItemIDTaken)) @@ -152,16 +164,12 @@ var _ = Describe("CatalogItem Service", func() { Context("when store returns service type not found error", func() { It("should return ErrServiceTypeNotFound", func() { - serviceTypeNonexistent := "nonexistent" req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Nonexistent Service Type", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeNonexistent, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 2}, - }, - }, + Spec: testutil.CatalogSpec("nonexistent", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 2}, + }), } result, err := svc.CatalogItem().Create(ctx, req) Expect(err).To(Equal(service.ErrServiceTypeNotFound)) @@ -176,19 +184,13 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Item 1", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Item 2", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}, - }, + Spec: testutil.CatalogSpecContainer([]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}), }) Expect(err).ToNot(HaveOccurred()) @@ -203,19 +205,13 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "VM Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Container Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}, - }, + Spec: testutil.CatalogSpecContainer([]v1alpha1.FieldConfiguration{{Path: "spec.image", Default: "nginx"}}), }) Expect(err).ToNot(HaveOccurred()) @@ -223,7 +219,7 @@ var _ = Describe("CatalogItem Service", func() { result, err := svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ServiceType: &svcType}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(*result.CatalogItems[0].Spec.ServiceType).To(Equal(serviceTypeVM)) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal(serviceTypeVM)) }) }) @@ -233,10 +229,7 @@ var _ = Describe("CatalogItem Service", func() { _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) } @@ -276,10 +269,7 @@ var _ = Describe("CatalogItem Service", func() { created, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) Expect(created.Uid).ToNot(BeNil()) @@ -309,10 +299,7 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Old Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) @@ -335,20 +322,14 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) - newSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu", Default: 4}, - {Path: "spec.memory", Default: "8GB"}, - }, - } + newSpec := testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu", Default: 4}, + {Path: "spec.memory", Default: "8GB"}, + }) req := &service.UpdateCatalogItemRequest{ Spec: newSpec, } @@ -356,36 +337,30 @@ var _ = Describe("CatalogItem Service", func() { result, err := svc.CatalogItem().Update(ctx, "item1", req) Expect(err).ToNot(HaveOccurred()) Expect(result).ToNot(BeNil()) - Expect(*result.Spec.Fields).To(HaveLen(2)) + Expect(*result.Spec.Resources[0].Fields).To(HaveLen(2)) }) }) Context("attempting to update spec.service_type (immutable)", func() { - It("should return ErrImmutableFieldUpdate", func() { + It("should return ErrImmutableSpecStructureUpdate", func() { id := "item1" _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ ID: &id, ApiVersion: "v1alpha1", DisplayName: "Name", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) - newSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeContainer, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.image", Default: "nginx"}, - }, - } + newSpec := testutil.PtrCatalogSpec("container", []v1alpha1.FieldConfiguration{ + {Path: "spec.image", Default: "nginx"}, + }) req := &service.UpdateCatalogItemRequest{ Spec: newSpec, } result, err := svc.CatalogItem().Update(ctx, "item1", req) - Expect(err).To(Equal(service.ErrImmutableFieldUpdate)) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) Expect(result).To(BeNil()) }) }) @@ -410,33 +385,30 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Cyclic DependsOn", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, }, }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, - }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -450,44 +422,41 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Three-Field Cycle", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, }, }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.disk.size_gb", - AllowedValues: map[string][]any{ - "100": {float64(4), float64(8)}, - }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.disk.size_gb", + AllowedValues: map[string][]any{ + "100": {float64(4), float64(8)}, }, }, - { - Path: "spec.disk.size_gb", - Default: float64(100), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(100), float64(200)}, - }, + }, + { + Path: "spec.disk.size_gb", + Default: float64(100), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(100), float64(200)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -501,28 +470,25 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Valid DependsOn", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - }, - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, - "4": {float64(8), float64(16)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, + "4": {float64(8), float64(16)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -537,22 +503,19 @@ var _ = Describe("CatalogItem Service", func() { req := &service.CreateCatalogItemRequest{ ApiVersion: "v1alpha1", DisplayName: "Invalid DependsOn Path", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.region", - AllowedValues: map[string][]any{ - "us-central1": {float64(4), float64(8)}, - }, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.region", + AllowedValues: map[string][]any{ + "us-central1": {float64(4), float64(8)}, }, }, }, - }, + }), } result, err := svc.CatalogItem().Create(ctx, req) @@ -572,46 +535,214 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "No Cycle", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: float64(2), Editable: &editable}, - {Path: "spec.memory.size_gb", Default: float64(4), Editable: &editable}, + Spec: testutil.CatalogSpec("vm", []v1alpha1.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: float64(2), Editable: &editable}, + {Path: "spec.memory.size_gb", Default: float64(4), Editable: &editable}, + }), + }) + Expect(err).ToNot(HaveOccurred()) + + updateSpec := testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Default: float64(2), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.memory.size_gb", + AllowedValues: map[string][]any{ + "4": {float64(2), float64(4)}, + }, + }, + }, + { + Path: "spec.memory.size_gb", + Default: float64(4), + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "spec.vcpu.count", + AllowedValues: map[string][]any{ + "2": {float64(4), float64(8)}, + }, }, }, }) + + result, err := svc.CatalogItem().Update(ctx, id, &service.UpdateCatalogItemRequest{ + Spec: updateSpec, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cycle")) + Expect(result).To(BeNil()) + }) + }) + + Describe("Create multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + }) + + It("should create a multi-resource catalog item with resources", func() { + id := "dev-app" + req := &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + } + + result, err := svc.CatalogItem().Create(ctx, req) Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(*result.Uid).To(Equal(id)) + Expect(result.Spec.Resources).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + Expect((result.Spec.Resources)[0].Name).To(Equal("ordersDb")) + Expect((result.Spec.Resources)[1].RequiresResources).ToNot(BeNil()) + Expect(*(result.Spec.Resources)[1].RequiresResources).To(Equal([]string{"ordersDb"})) + }) - updateSpec := &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{ + It("should round-trip multi-resource spec on get", func() { + id := "dev-app-get" + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + + result, err := svc.CatalogItem().Get(ctx, id) + Expect(err).ToNot(HaveOccurred()) + Expect(result.Spec.Resources).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + }) + + It("should reject empty resources", func() { + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Empty resources", + Spec: v1alpha1.CatalogItemSpec{Resources: []v1alpha1.CatalogResource{}}, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemSpecConflict)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject duplicate resource names", func() { + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database"}, + {Name: "ordersDb", ServiceType: "container"}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Duplicate names", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemResourceNameTaken)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject unknown requires_resources reference", func() { + requiresMissing := []string{"missing"} + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ { - Path: "spec.vcpu.count", - Default: float64(2), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.memory.size_gb", - AllowedValues: map[string][]any{ - "4": {float64(2), float64(4)}, - }, - }, + Name: "app", + ServiceType: "container", + RequiresResources: &requiresMissing, }, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad requires", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemRequiresResourceNotFound)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject cyclic requires_resources", func() { + requiresApp := []string{"app"} + requiresDb := []string{"ordersDb"} + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database", RequiresResources: &requiresApp}, + {Name: "app", ServiceType: "container", RequiresResources: &requiresDb}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Cycle", + Spec: spec, + }) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCatalogItemRequiresCycle)).To(BeTrue()) + Expect(result).To(BeNil()) + }) + + It("should reject resource with unknown service type", func() { + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "nonexistent"}, + }, + } + + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad service type", + Spec: spec, + }) + Expect(err).To(Equal(service.ErrServiceTypeNotFound)) + Expect(result).To(BeNil()) + }) + + It("should reject cyclic depends_on within a resource fields", func() { + editable := true + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ { - Path: "spec.memory.size_gb", - Default: float64(4), - Editable: &editable, - DependsOn: &v1alpha1.FieldConfigurationDependsOn{ - Path: "spec.vcpu.count", - AllowedValues: map[string][]any{ - "2": {float64(4), float64(8)}, + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + { + Path: "version", + Default: "16", + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "engine", + AllowedValues: map[string][]any{ + "postgres": {"14", "16"}, + }, + }, + }, + { + Path: "engine", + Default: "postgres", + Editable: &editable, + DependsOn: &v1alpha1.FieldConfigurationDependsOn{ + Path: "version", + AllowedValues: map[string][]any{ + "16": {"postgres"}, + }, + }, }, }, }, }, } - result, err := svc.CatalogItem().Update(ctx, id, &service.UpdateCatalogItemRequest{ - Spec: updateSpec, + result, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Field cycle", + Spec: spec, }) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("cycle")) @@ -619,6 +750,117 @@ var _ = Describe("CatalogItem Service", func() { }) }) + Describe("Update multi-resource catalog item", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + id := "dev-app-update" + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ID: &id, + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + }) + + It("should update field defaults within a resource", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].Fields = &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "mysql"}, + {Path: "version", Default: "8.0"}, + } + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + Expect(result.Spec.Resources).To(HaveLen(2)) + Expect(*(result.Spec.Resources)[0].Fields).To(HaveLen(2)) + Expect((*(result.Spec.Resources)[0].Fields)[0].Default).To(Equal("mysql")) + }) + + It("should reject changing resource name", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].Name = "renamedDb" + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + + It("should reject changing resource service type", func() { + spec := devAppCatalogItemSpec() + (spec.Resources)[0].ServiceType = "vm" + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + + It("should reject changing requires_resources", func() { + spec := devAppCatalogItemSpec() + empty := []string{} + (spec.Resources)[1].RequiresResources = &empty + + result, err := svc.CatalogItem().Update(ctx, "dev-app-update", &service.UpdateCatalogItemRequest{ + Spec: &spec, + }) + Expect(err).To(Equal(service.ErrImmutableSpecStructureUpdate)) + Expect(result).To(BeNil()) + }) + }) + + Describe("List catalog items by resource service type", func() { + BeforeEach(func() { + ensureServiceType(ctx, str, "db-st", "database") + ensureServiceType(ctx, str, "ctr-st", "container") + }) + + It("returns items where any resource matches the filter", func() { + _, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Single-resource VM", + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), + }) + Expect(err).ToNot(HaveOccurred()) + _, err = svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev Application", + Spec: devAppCatalogItemSpec(), + }) + Expect(err).ToNot(HaveOccurred()) + + vmFilter := "vm" + result, err := svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &vmFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Single-resource VM")) + + dbFilter := "database" + result, err = svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &dbFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Dev Application")) + + containerFilter := "container" + result, err = svc.CatalogItem().List(ctx, service.CatalogItemListOptions{ + ServiceType: &containerFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(*result.CatalogItems[0].DisplayName).To(Equal("Dev Application")) + }) + }) + Describe("Delete", func() { Context("with existing item", func() { It("should delete the catalog item", func() { @@ -627,10 +869,7 @@ var _ = Describe("CatalogItem Service", func() { ID: &id, ApiVersion: "v1alpha1", DisplayName: "To Delete", - Spec: v1alpha1.CatalogItemSpec{ - ServiceType: &serviceTypeVM, - Fields: &[]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}, - }, + Spec: testutil.CatalogSpecVM([]v1alpha1.FieldConfiguration{{Path: "spec.vcpu", Default: 2}}), }) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/service/catalog_item_validation.go b/internal/catalog/service/catalog_item_validation.go new file mode 100644 index 0000000..896143a --- /dev/null +++ b/internal/catalog/service/catalog_item_validation.go @@ -0,0 +1,201 @@ +package service + +import ( + "context" + "fmt" + + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// validateCatalogItemSpec checks a catalog item spec on create and update. +// Validates resources, required fields, and delegates to +// resource-specific rules. Does not build or order a DAG — that is +// placement's job at instance time. +func validateCatalogItemSpec(ctx context.Context, store store.Store, spec model.CatalogItemSpec) error { + return validateCatalogResources(ctx, store, spec.Resources) +} + +// validateCatalogResources validates a catalog at authoring time: +// unique resource names, resolvable service types, valid requires_resources +// references, per-resource depends_on cycles, and no cycles in +// requires_resources. CEL in field defaults is validated at instance merge time. +func validateCatalogResources(ctx context.Context, store store.Store, resources []model.CatalogResource) error { + if len(resources) == 0 { + return fmt.Errorf("%w: resources must not be empty", ErrCatalogItemSpecConflict) + } + + seen := make(map[string]bool, len(resources)) + for _, r := range resources { + if r.Name == "" { + return fmt.Errorf("%w: resource name is required", ErrCatalogItemSpecConflict) + } + if seen[r.Name] { + return fmt.Errorf("%w: %s", ErrCatalogItemResourceNameTaken, r.Name) + } + seen[r.Name] = true + + if r.ServiceType == "" { + return fmt.Errorf("%w: resource %s service_type is required", ErrCatalogItemSpecConflict, r.Name) + } + if _, err := store.ServiceType().GetByServiceType(ctx, r.ServiceType); err != nil { + return ErrServiceTypeNotFound + } + if err := validateFieldDependsOnCycles(r.Fields); err != nil { + return fmt.Errorf("resource %s: %w", r.Name, err) + } + } + + for _, r := range resources { + for _, dep := range r.RequiresResources { + if !seen[dep] { + return fmt.Errorf("%w: %s", ErrCatalogItemRequiresResourceNotFound, dep) + } + } + } + + if err := validateRequiresResourcesCycles(resources); err != nil { + return err + } + return nil +} + +// detectDirectedCycle reports a cycle in a directed graph where edges[node] lists +// predecessor nodes that node depends on (each must be satisfied before node). +func detectDirectedCycle(edges map[string][]string, cycleErr error) error { + if len(edges) == 0 { + return nil + } + + const ( + unvisited = 0 + visiting = 1 + visited = 2 + ) + state := make(map[string]int) + + var visit func(node string) error + visit = func(node string) error { + if state[node] == visited { + return nil + } + if state[node] == visiting { + return fmt.Errorf("%w: cycle involving %s", cycleErr, node) + } + state[node] = visiting + for _, dep := range edges[node] { + if err := visit(dep); err != nil { + return err + } + } + state[node] = visited + return nil + } + + for node := range edges { + if err := visit(node); err != nil { + return err + } + } + return nil +} + +// validateFieldDependsOnCycles checks that every depends_on path references an existing +// field and that there are no cyclic depends_on references within one field set. +func validateFieldDependsOnCycles(fields []model.FieldConfiguration) error { + knownPaths := make(map[string]bool, len(fields)) + for _, f := range fields { + knownPaths[f.Path] = true + } + + edges := make(map[string][]string) + for _, f := range fields { + if f.DependsOn == nil { + continue + } + depPath := f.DependsOn.Path + if !knownPaths[depPath] { + return fmt.Errorf("%w: field %s depends_on path %q not found in fields", ErrDependsOnPathNotFound, f.Path, depPath) + } + edges[f.Path] = []string{depPath} + } + + return detectDirectedCycle(edges, ErrDependsOnCycleDetected) +} + +// validateRequiresResourcesCycles detects cycles in requires_resources edges. +// Authoring-time guard only; placement repeats DAG validation when admitting a run. +func validateRequiresResourcesCycles(resources []model.CatalogResource) error { + edges := make(map[string][]string, len(resources)) + for _, r := range resources { + edges[r.Name] = append([]string(nil), r.RequiresResources...) + } + return detectDirectedCycle(edges, ErrCatalogItemRequiresCycle) +} + +// validateCatalogImmutable ensures structure is not changed on +// update (resource names, service types, requires_resources). Field defaults and +// validation rules within each resource may still change. +func validateCatalogImmutable(existing, updated model.CatalogItemSpec) error { + if len(existing.Resources) != len(updated.Resources) { + return ErrImmutableSpecStructureUpdate + } + + updatedByName := make(map[string]model.CatalogResource, len(updated.Resources)) + for _, r := range updated.Resources { + updatedByName[r.Name] = r + } + + for _, oldR := range existing.Resources { + newR, ok := updatedByName[oldR.Name] + if !ok { + return ErrImmutableSpecStructureUpdate + } + if oldR.ServiceType != newR.ServiceType || + !sameStringSlice(oldR.RequiresResources, newR.RequiresResources) { + return ErrImmutableSpecStructureUpdate + } + } + return nil +} + +func sameStringSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// userValuesForResource returns user values that target the given resource name. +func userValuesForResource(userValues []v1alpha1.UserValue, resourceName string) []v1alpha1.UserValue { + out := make([]v1alpha1.UserValue, 0) + for _, uv := range userValues { + if uv.Resource == resourceName { + out = append(out, uv) + } + } + return out +} + +// validateUserValuesForCatalogItem checks instance user_values against the catalog. +func validateUserValuesForCatalogItem(spec model.CatalogItemSpec, userValues []v1alpha1.UserValue) error { + known := make(map[string]bool, len(spec.Resources)) + for _, r := range spec.Resources { + known[r.Name] = true + } + for _, uv := range userValues { + if uv.Resource == "" { + return ErrUserValueResourceRequired + } + if !known[uv.Resource] { + return fmt.Errorf("%w: %s", ErrUserValueResourceNotFound, uv.Resource) + } + } + return nil +} diff --git a/internal/catalog/service/cel_validation.go b/internal/catalog/service/cel_validation.go new file mode 100644 index 0000000..84177f8 --- /dev/null +++ b/internal/catalog/service/cel_validation.go @@ -0,0 +1,106 @@ +package service + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// celReferencePattern matches restricted catalog CEL: ${resourceName.outputField} +var celReferencePattern = regexp.MustCompile(`^\$\{([a-zA-Z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)\}$`) + +type celReference struct { + ResourceName string + OutputField string +} + +func parseCELReference(value string) (celReference, bool, error) { + if !strings.Contains(value, "${") { + return celReference{}, false, nil + } + matches := celReferencePattern.FindStringSubmatch(value) + if matches == nil { + return celReference{}, true, fmt.Errorf("%w: %q", ErrInvalidCELExpression, value) + } + return celReference{ + ResourceName: matches[1], + OutputField: matches[2], + }, true, nil +} + +// serviceTypeOutputNames returns declared output field names from a service type. +// Reads optional spec.outputs until outputs are formally defined on ServiceType. +func serviceTypeOutputNames(st *model.ServiceType) map[string]bool { + outputs := make(map[string]bool) + raw, ok := st.Spec["outputs"] + if !ok { + return outputs + } + m, ok := raw.(map[string]any) + if !ok { + return outputs + } + for name := range m { + outputs[name] = true + } + return outputs +} + +func validateCELReferenceValue( + ctx context.Context, + store store.Store, + resourcesByName map[string]model.CatalogResource, + consumerResourceName string, + fieldPath string, + value any, +) error { + str, ok := value.(string) + if !ok { + return nil + } + + ref, isCEL, err := parseCELReference(str) + if err != nil { + return err + } + if !isCEL { + return nil + } + + if ref.ResourceName == consumerResourceName { + return fmt.Errorf("%w: field %s", ErrCELSelfReference, fieldPath) + } + + source, ok := resourcesByName[ref.ResourceName] + if !ok { + return fmt.Errorf("%w: %s", ErrCELResourceNotFound, ref.ResourceName) + } + + sourceST, err := store.ServiceType().GetByServiceType(ctx, source.ServiceType) + if err != nil { + return ErrServiceTypeNotFound + } + + outputs := serviceTypeOutputNames(sourceST) + if len(outputs) == 0 { + return fmt.Errorf("%w: service type %q has no declared outputs for %s.%s", + ErrCELServiceTypeOutputNotFound, source.ServiceType, ref.ResourceName, ref.OutputField) + } + if !outputs[ref.OutputField] { + return fmt.Errorf("%w: %s.%s", ErrCELServiceTypeOutputNotFound, ref.ResourceName, ref.OutputField) + } + + return nil +} + +func catalogResourcesByName(resources []model.CatalogResource) map[string]model.CatalogResource { + byName := make(map[string]model.CatalogResource, len(resources)) + for _, r := range resources { + byName[r.Name] = r + } + return byName +} diff --git a/internal/catalog/service/cel_validation_test.go b/internal/catalog/service/cel_validation_test.go new file mode 100644 index 0000000..1519241 --- /dev/null +++ b/internal/catalog/service/cel_validation_test.go @@ -0,0 +1,242 @@ +package service_test + +import ( + "context" + "errors" + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/config" + "github.com/dcm-project/control-plane/internal/catalog/service" + "github.com/dcm-project/control-plane/internal/catalog/store" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +func serviceTypeSpecWithOutputs(base map[string]any, outputs map[string]any) map[string]any { + spec := make(map[string]any, len(base)+1) + for k, v := range base { + spec[k] = v + } + spec["outputs"] = outputs + return spec +} + +func devAppCatalogItemSpecWithCEL() v1alpha1.CatalogItemSpec { + requiresOrdersDb := []string{"ordersDb"} + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: &requiresOrdersDb, + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + } +} + +var _ = Describe("CEL validation", func() { + var ( + ctx context.Context + db *gorm.DB + str store.Store + svc service.Service + ) + + BeforeEach(func() { + ctx = context.Background() + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + Expect(err).ToNot(HaveOccurred()) + Expect(db.Exec("PRAGMA foreign_keys = ON").Error).To(Succeed()) + Expect(db.AutoMigrate(&model.ServiceType{}, &model.CatalogItem{}, &model.CatalogItemInstance{})).To(Succeed()) + str = store.NewStore(db, slog.Default()) + svc, err = service.NewService(str, &mockPMClient{}, config.DefaultSeedConfig(), slog.Default()) + Expect(err).ToNot(HaveOccurred()) + + ensureServiceTypeWithSpec(ctx, str, "db-cel", "database", serviceTypeSpecWithOutputs( + map[string]any{"engine": "postgres"}, + map[string]any{"connectionString": map[string]any{"type": "string"}}, + )) + ensureServiceTypeWithSpec(ctx, str, "ctr-cel", "container", map[string]any{ + "image": map[string]any{"reference": "nginx"}, + "database_url": "", + }) + }) + + AfterEach(func() { + if str != nil { + Expect(str.Close()).To(Succeed()) + } + }) + + createCatalogItemWithSpec := func(spec v1alpha1.CatalogItemSpec) string { + ci, err := svc.CatalogItem().Create(ctx, &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Dev App CEL", + Spec: spec, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(ci.Uid).ToNot(BeNil()) + return *ci.Uid + } + + instanceCreateReq := func(catalogItemID string) *service.CreateCatalogItemInstanceRequest { + return &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Instance", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: catalogItemID, + UserValues: []v1alpha1.UserValue{}, + }, + } + } + + Describe("catalog item create", func() { + It("accepts field defaults containing CEL without validating references", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${missingDb.connectionString}" + req := &service.CreateCatalogItemRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Deferred CEL", + Spec: spec, + } + result, err := svc.CatalogItem().Create(ctx, req) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + }) + }) + + Describe("catalog instance create", func() { + It("creates instance when CEL defaults validate during merge", func() { + catalogItemID := createCatalogItemWithSpec(devAppCatalogItemSpecWithCEL()) + result, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).ToNot(HaveOccurred()) + Expect(result).ToNot(BeNil()) + }) + + It("rejects malformed CEL expressions during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "prefix-${ordersDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrInvalidCELExpression)).To(BeTrue()) + }) + + It("rejects CEL referencing unknown catalog resource during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${missingDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELResourceNotFound)).To(BeTrue()) + }) + + It("rejects CEL referencing unknown service type output during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[1].Fields)[0].Default = "${ordersDb.connectionStrng}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELServiceTypeOutputNotFound)).To(BeTrue()) + }) + + It("rejects CEL self-reference during merge", func() { + spec := devAppCatalogItemSpecWithCEL() + (*spec.Resources[0].Fields)[0].Default = "${ordersDb.connectionString}" + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELSelfReference)).To(BeTrue()) + }) + + It("rejects CEL when source service type declares no outputs during merge", func() { + ensureServiceTypeWithSpec(ctx, str, "db-no-out", "database-no-outputs", map[string]any{ + "engine": "postgres", + }) + spec := v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{ + {Name: "ordersDb", ServiceType: "database-no-outputs"}, + { + Name: "app", + ServiceType: "container", + Fields: &[]v1alpha1.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + } + catalogItemID := createCatalogItemWithSpec(spec) + _, err := svc.CatalogItemInstance().Create(ctx, instanceCreateReq(catalogItemID)) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrCELServiceTypeOutputNotFound)).To(BeTrue()) + }) + + It("rejects user_values containing CEL expressions", func() { + catalogItemID := createCatalogItemWithSpec(devAppCatalogItemSpecWithCEL()) + req := &service.CreateCatalogItemInstanceRequest{ + ApiVersion: "v1alpha1", + DisplayName: "Bad User CEL", + Spec: v1alpha1.CatalogItemInstanceSpec{ + CatalogItemId: catalogItemID, + UserValues: []v1alpha1.UserValue{ + {Resource: "app", Path: "database_url", Value: "${ordersDb.connectionString}"}, + }, + }, + } + _, err := svc.CatalogItemInstance().Create(ctx, req) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, service.ErrUserValueCELNotAllowed)).To(BeTrue()) + }) + }) + + Describe("BuildResourceGraph", func() { + It("preserves CEL reference in merged spec after validation", func() { + ci := model.CatalogItem{ + ID: "graph-cel", + ApiVersion: "v1alpha1", + DisplayName: "Graph CEL", + Path: "catalog-items/graph-cel", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + {Name: "ordersDb", ServiceType: "database", Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres"}, + }}, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "database_url", Default: "${ordersDb.connectionString}"}, + }, + }, + }, + }, + } + _, err := str.CatalogItem().Create(ctx, ci) + Expect(err).ToNot(HaveOccurred()) + + builder := service.NewSpecBuilderForTest(str) + graph, err := builder.BuildResourceGraph(ctx, "graph-cel", nil) + Expect(err).ToNot(HaveOccurred()) + Expect(graph).To(HaveLen(2)) + Expect(graph[1].Spec["database_url"]).To(Equal("${ordersDb.connectionString}")) + }) + }) +}) diff --git a/internal/catalog/service/errors.go b/internal/catalog/service/errors.go index 23d25cc..5cdbdba 100644 --- a/internal/catalog/service/errors.go +++ b/internal/catalog/service/errors.go @@ -25,8 +25,8 @@ var ( // ErrCatalogItemHasInstances indicates a catalog item has existing instances ErrCatalogItemHasInstances = errors.New("catalog item has existing instances") - // ErrImmutableFieldUpdate indicates an attempt to change api_version or spec.service_type - ErrImmutableFieldUpdate = errors.New("cannot update immutable fields: api_version and spec.service_type are immutable") + // ErrImmutableSpecStructureUpdate indicates an attempt to change immutable catalog item structure + ErrImmutableSpecStructureUpdate = errors.New("cannot update immutable catalog item fields: resource names, service types, and requires_resources are immutable") // ErrCatalogItemInstanceNotFound indicates the requested catalog item instance does not exist ErrCatalogItemInstanceNotFound = errors.New("catalog item instance not found") @@ -55,9 +55,45 @@ var ( // ErrDependsOnPathNotFound indicates a depends_on path does not reference any field in the catalog item ErrDependsOnPathNotFound = errors.New("depends_on path does not reference an existing field") + // ErrCatalogItemSpecConflict indicates an invalid catalog item spec + ErrCatalogItemSpecConflict = errors.New("invalid catalog item spec") + + // ErrCatalogItemResourceNameTaken indicates duplicate resource names in a catalog item + ErrCatalogItemResourceNameTaken = errors.New("duplicate resource name in catalog item") + + // ErrCatalogItemRequiresResourceNotFound indicates requires_resources references an unknown resource name + ErrCatalogItemRequiresResourceNotFound = errors.New("requires_resources references unknown resource name") + + // ErrCatalogItemRequiresCycle indicates a cycle in requires_resources dependencies + ErrCatalogItemRequiresCycle = errors.New("cycle detected in requires_resources dependencies") + + // ErrUserValueResourceRequired indicates a user_value is missing the resource name + ErrUserValueResourceRequired = errors.New("user value resource is required") + + // ErrUserValueResourceNotFound indicates a user_value resource does not match any catalog resource + ErrUserValueResourceNotFound = errors.New("user value resource not found in catalog item") + + // ErrMultiResourceRehydrateNotSupported indicates rehydrate is not supported for multi-resource instances + ErrMultiResourceRehydrateNotSupported = errors.New("rehydrate is not supported for multi-resource catalog item instances") + // ErrUserValueDependsOnViolation indicates the user value is not allowed given the current value of the field it depends on ErrUserValueDependsOnViolation = errors.New("user value violates depends_on constraint") + // ErrInvalidCELExpression indicates a string is not a valid restricted CEL reference + ErrInvalidCELExpression = errors.New("invalid CEL expression: must match ${resourceName.outputField}") + + // ErrCELResourceNotFound indicates a CEL reference targets an unknown catalog resource + ErrCELResourceNotFound = errors.New("CEL reference resource not found in catalog item") + + // ErrCELSelfReference indicates a resource references its own output via CEL + ErrCELSelfReference = errors.New("CEL reference cannot target the same resource") + + // ErrCELServiceTypeOutputNotFound indicates the referenced output is not declared on the source service type + ErrCELServiceTypeOutputNotFound = errors.New("CEL reference output not found on service type") + + // ErrUserValueCELNotAllowed indicates user_values cannot contain CEL expressions + ErrUserValueCELNotAllowed = errors.New("user values cannot contain CEL expressions") + // ErrPlacementManagerPolicyRejected indicates the Placement Manager rejected the request due to policy (406) ErrPlacementManagerPolicyRejected = errors.New("placement manager request rejected by policy engine") diff --git a/internal/catalog/service/export_test.go b/internal/catalog/service/export_test.go index 6a877d4..f733009 100644 --- a/internal/catalog/service/export_test.go +++ b/internal/catalog/service/export_test.go @@ -17,7 +17,7 @@ func NewSpecBuilderForTest(s store.Store) *SpecBuilder { return &SpecBuilder{inner: newSpecBuilder(s)} } -// BuildResourceSpec delegates to the unexported specBuilder. -func (b *SpecBuilder) BuildResourceSpec(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { - return b.inner.BuildResourceSpec(ctx, catalogItemId, userValues) +// BuildResourceGraph delegates to the unexported specBuilder. +func (b *SpecBuilder) BuildResourceGraph(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) ([]ResolvedResource, error) { + return b.inner.BuildResourceGraph(ctx, catalogItemId, userValues) } diff --git a/internal/catalog/service/seed.go b/internal/catalog/service/seed.go index 5f69981..f58073d 100644 --- a/internal/catalog/service/seed.go +++ b/internal/catalog/service/seed.go @@ -94,10 +94,12 @@ func (s *service) petClinicCatalogItem() model.CatalogItem { DisplayName: "Pet Clinic", Path: "catalog-items/pet-clinic", Spec: model.CatalogItemSpec{ - ServiceType: "three-tier-app-demo", - Fields: s.petClinicFields(), + Resources: []model.CatalogResource{{ + Name: "app", + ServiceType: "three-tier-app-demo", + Fields: s.petClinicFields(), + }}, }, - SpecServiceType: "three-tier-app-demo", } } diff --git a/internal/catalog/service/seed_test.go b/internal/catalog/service/seed_test.go index 0ba2f7a..fc3f8e5 100644 --- a/internal/catalog/service/seed_test.go +++ b/internal/catalog/service/seed_test.go @@ -16,6 +16,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("Seed", func() { @@ -119,12 +120,12 @@ var _ = Describe("Seed", func() { Expect(ci.ID).To(Equal("pet-clinic")) Expect(ci.DisplayName).To(Equal("Pet Clinic")) Expect(ci.Path).To(Equal("catalog-items/pet-clinic")) - Expect(ci.Spec.ServiceType).To(Equal("three-tier-app-demo")) - Expect(ci.Spec.Fields).To(HaveLen(5)) + Expect(ci.Spec.Resources[0].ServiceType).To(Equal("three-tier-app-demo")) + Expect(ci.Spec.Resources[0].Fields).To(HaveLen(5)) // Verify key field configs - fieldPaths := make([]string, len(ci.Spec.Fields)) - for i, f := range ci.Spec.Fields { + fieldPaths := make([]string, len(ci.Spec.Resources[0].Fields)) + for i, f := range ci.Spec.Resources[0].Fields { fieldPaths[i] = f.Path } Expect(fieldPaths).To(ContainElement("metadata.labels.region")) @@ -134,7 +135,7 @@ var _ = Describe("Seed", func() { Expect(fieldPaths).To(ContainElement("web.image")) // Verify region field uses configured values - regionField := findFieldByPath(ci.Spec.Fields, "metadata.labels.region") + regionField := findFieldByPath(ci.Spec.Resources[0].Fields, "metadata.labels.region") Expect(regionField).ToNot(BeNil()) Expect(regionField.Editable).To(BeTrue()) Expect(regionField.Default).To(BeNil()) @@ -145,7 +146,7 @@ var _ = Describe("Seed", func() { Expect(regionEnum).To(ConsistOf("region-a", "region-b")) // Verify database.engine is editable and has validation schema enum - dbEngineField := findFieldByPath(ci.Spec.Fields, "database.engine") + dbEngineField := findFieldByPath(ci.Spec.Resources[0].Fields, "database.engine") Expect(dbEngineField).ToNot(BeNil()) Expect(dbEngineField.Editable).To(BeTrue()) Expect(dbEngineField.Default).To(Equal(three_tier_app_demo.DefaultDatabaseEngine)) @@ -156,7 +157,7 @@ var _ = Describe("Seed", func() { Expect(enumVals).To(ConsistOf("postgres", "mysql")) // Verify database.version has dependsOn on database.engine and is properly constrained - dbVersionField := findFieldByPath(ci.Spec.Fields, "database.version") + dbVersionField := findFieldByPath(ci.Spec.Resources[0].Fields, "database.version") Expect(dbVersionField).ToNot(BeNil()) Expect(dbVersionField.Editable).To(BeTrue()) Expect(dbVersionField.Default).To(Equal(three_tier_app_demo.DefaultDatabaseVersion)) @@ -170,12 +171,12 @@ var _ = Describe("Seed", func() { Expect(dbVersionField.DependsOn.AllowedValues["mysql"]).To(ConsistOf("8.4", "8.3", "8")) // Verify app.image and web.image fixed defaults - appImageField := findFieldByPath(ci.Spec.Fields, "app.image") + appImageField := findFieldByPath(ci.Spec.Resources[0].Fields, "app.image") Expect(appImageField).ToNot(BeNil()) Expect(appImageField.Default).To(Equal(three_tier_app_demo.AppImage)) Expect(appImageField.Editable).To(BeFalse()) - webImageField := findFieldByPath(ci.Spec.Fields, "web.image") + webImageField := findFieldByPath(ci.Spec.Resources[0].Fields, "web.image") Expect(webImageField).ToNot(BeNil()) Expect(webImageField.Default).To(Equal(three_tier_app_demo.WebImage)) Expect(webImageField.Editable).To(BeFalse()) @@ -205,11 +206,8 @@ var _ = Describe("Seed", func() { ID: "existing-item", ApiVersion: "v1alpha1", DisplayName: "Existing", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/existing-item", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/existing-item", } _, err := dataStore.CatalogItem().Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/service/spec_builder.go b/internal/catalog/service/spec_builder.go index f6af807..4be9cd5 100644 --- a/internal/catalog/service/spec_builder.go +++ b/internal/catalog/service/spec_builder.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "github.com/dcm-project/control-plane/api/catalog/v1alpha1" "github.com/dcm-project/control-plane/internal/catalog/store" @@ -15,6 +16,14 @@ import ( // ServiceTypeKey is the key for the service_type field in the spec map const ServiceTypeKey = "service_type" +// ResolvedResource is a catalog resource after resolution. +type ResolvedResource struct { + Name string + ServiceType string + RequiresResources []string + Spec map[string]any +} + // specBuilder resolves the reference chain and constructs the final resource spec type specBuilder struct { store store.Store @@ -25,13 +34,11 @@ func newSpecBuilder(store store.Store) *specBuilder { return &specBuilder{store: store} } -// BuildResourceSpec resolves the reference chain (CatalogItemInstance → CatalogItem → ServiceType) -// and constructs the final resource spec by: -// 1. Deep-copying the ServiceType spec as the base template -// 2. Applying CatalogItem field defaults -// 3. Applying user_values on top (with validation) -func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { - // 1. Look up CatalogItem +// BuildResourceGraph resolves a catalog item to an effective resource graph. +// Each node includes merged specs and requires_resources edges for placement. +// Resource order matches catalog item order; DAG sort and level-by-level provisioning +// are placement's responsibility. +func (b *specBuilder) BuildResourceGraph(ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) ([]ResolvedResource, error) { catalogItem, err := b.store.CatalogItem().Get(ctx, catalogItemId) if err != nil { if errors.Is(err, store.ErrCatalogItemNotFound) { @@ -40,32 +47,68 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin return nil, err } - // 2. Look up ServiceType by CatalogItem's service_type - serviceType, err := b.store.ServiceType().GetByServiceType(ctx, catalogItem.Spec.ServiceType) + if err := validateUserValuesForCatalogItem(catalogItem.Spec, userValues); err != nil { + return nil, err + } + + out := make([]ResolvedResource, 0, len(catalogItem.Spec.Resources)) + resourcesByName := catalogResourcesByName(catalogItem.Spec.Resources) + for _, resource := range catalogItem.Spec.Resources { + resourceUserValues := userValuesForResource(userValues, resource.Name) + specMap, err := b.buildResourceSpecFromFields(ctx, resourcesByName, resource, resourceUserValues) + if err != nil { + return nil, fmt.Errorf("resource %s: %w", resource.Name, err) + } + out = append(out, ResolvedResource{ + Name: resource.Name, + ServiceType: resource.ServiceType, + RequiresResources: append([]string(nil), resource.RequiresResources...), + Spec: specMap, + }) + } + return out, nil +} + +// buildResourceSpecFromFields merges a catalog resource's field configuration and +// instance user values onto the service type base spec, producing the effective +// spec for one node in the resource graph. +// +// Merge order: service type spec → catalog field defaults → user values. +// CEL references (${resource.output}) in defaults are validated at merge time +// when the full resource graph is known; user_values must not contain CEL. +func (b *specBuilder) buildResourceSpecFromFields( + ctx context.Context, + resourcesByName map[string]model.CatalogResource, + resource model.CatalogResource, + userValues []v1alpha1.UserValue, +) (map[string]any, error) { + serviceTypeName := resource.ServiceType + fields := resource.Fields + serviceType, err := b.store.ServiceType().GetByServiceType(ctx, serviceTypeName) if err != nil { - return nil, fmt.Errorf("failed to resolve service type %q: %w", catalogItem.Spec.ServiceType, err) + return nil, fmt.Errorf("failed to resolve service type %q: %w", serviceTypeName, err) } - // 3. Deep-copy ServiceType spec as base template + // Start from a copy of the service type spec so catalog overlays do not mutate the stored template. specMap, err := deepCopyMap(serviceType.Spec) if err != nil { return nil, fmt.Errorf("failed to copy service type spec: %w", err) } - - // 3.1. Set service_type from the ServiceType instance specMap[ServiceTypeKey] = serviceType.ServiceType - // 4. Build a lookup map of CatalogItem fields by path fieldsByPath := make(map[string]model.FieldConfiguration) - for _, field := range catalogItem.Spec.Fields { + for _, field := range fields { fieldsByPath[field.Path] = field } - // 5. Apply CatalogItem field defaults (validated against schema when present) - for _, field := range catalogItem.Spec.Fields { + // Apply catalog field defaults: validate CEL and schema, then overlay onto specMap. + for _, field := range fields { if field.Default == nil { continue } + if err := validateCELReferenceValue(ctx, b.store, resourcesByName, resource.Name, field.Path, field.Default); err != nil { + return nil, err + } if field.ValidationSchema != nil { if err := validateAgainstSchema(field.ValidationSchema, field.Default); err != nil { return nil, fmt.Errorf("%w: %s: %s", ErrFieldDefaultValidationFailed, field.Path, err.Error()) @@ -76,33 +119,29 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin } } - // 6. Apply user_values on top (with path, editable, and schema validation) + // Apply instance user values on editable fields only. for _, uv := range userValues { - // Validate: user_value path must match a CatalogItem field + if isCELStringValue(uv.Value) { + return nil, fmt.Errorf("%w: %s", ErrUserValueCELNotAllowed, uv.Path) + } field, ok := fieldsByPath[uv.Path] if !ok { return nil, fmt.Errorf("%w: %s", ErrUserValuePathNotFound, uv.Path) } - - // Validate: field must be editable if !field.Editable { return nil, fmt.Errorf("%w: %s", ErrUserValueNotEditable, uv.Path) } - - // Validate: if field has a validation_schema, validate the value against it if field.ValidationSchema != nil { if err := validateAgainstSchema(field.ValidationSchema, uv.Value); err != nil { return nil, fmt.Errorf("%w: %s: %s", ErrUserValueValidationFailed, uv.Path, err.Error()) } } - - // Apply the user value if err := setNestedValue(specMap, uv.Path, uv.Value); err != nil { return nil, fmt.Errorf("failed to set user value for field %q: %w", uv.Path, err) } } - // 7. Validate depends_on constraints against final spec (all user values applied) + // depends_on checks run after merge so source fields are resolved in specMap. for _, uv := range userValues { field := fieldsByPath[uv.Path] if field.DependsOn != nil { @@ -115,6 +154,14 @@ func (b *specBuilder) BuildResourceSpec(ctx context.Context, catalogItemId strin return specMap, nil } +func isCELStringValue(value any) bool { + str, ok := value.(string) + if !ok { + return false + } + return strings.Contains(str, "${") +} + // deepCopyMap creates a deep copy of a map[string]any by marshaling/unmarshaling JSON func deepCopyMap(src map[string]any) (map[string]any, error) { data, err := json.Marshal(src) @@ -139,8 +186,6 @@ func validateAgainstSchema(schema map[string]any, value any) error { return fmt.Errorf("failed to compile schema: %w", err) } - // JSON Schema validation requires the value to go through JSON round-trip - // to ensure types match (e.g., int vs float64) data, err := json.Marshal(value) if err != nil { return fmt.Errorf("failed to marshal value: %w", err) @@ -154,8 +199,6 @@ func validateAgainstSchema(schema map[string]any, value any) error { } // validateDependsOn validates a user value against a field's depends_on constraint. -// It looks up the source field's current value in the spec, then checks that the -// user's value is among the allowed values for that source value. func validateDependsOn(specMap map[string]any, dep *model.DependsOn, fieldPath string, userValue any) error { sourceValue, err := getNestedValue(specMap, dep.Path) if err != nil { @@ -176,7 +219,6 @@ func validateDependsOn(specMap map[string]any, dep *model.DependsOn, fieldPath s } // containsValue checks if target is present in arr using JSON comparison -// to handle type differences (e.g., float64 vs int). func containsValue(arr []any, target any) bool { targetJSON, err := json.Marshal(target) if err != nil { diff --git a/internal/catalog/service/spec_builder_test.go b/internal/catalog/service/spec_builder_test.go index f6ed87b..e33e6bf 100644 --- a/internal/catalog/service/spec_builder_test.go +++ b/internal/catalog/service/spec_builder_test.go @@ -3,6 +3,7 @@ package service_test import ( "context" "errors" + "fmt" "log/slog" . "github.com/onsi/ginkgo/v2" @@ -16,8 +17,20 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/service" "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) +func buildGraphSpec(builder *service.SpecBuilder, ctx context.Context, catalogItemId string, userValues []v1alpha1.UserValue) (map[string]any, error) { + graph, err := builder.BuildResourceGraph(ctx, catalogItemId, userValues) + if err != nil { + return nil, err + } + if len(graph) == 0 { + return nil, fmt.Errorf("empty graph") + } + return graph[0].Spec, nil +} + var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { var ( ctx context.Context @@ -68,7 +81,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-chain", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(16)}, }, }, } @@ -91,7 +104,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-bad-path", UserValues: []v1alpha1.UserValue{ - {Path: "spec.network.bandwidth", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.network.bandwidth", Value: float64(100)}, }, }, } @@ -112,7 +125,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-not-editable", UserValues: []v1alpha1.UserValue{ - {Path: "spec.disk.size_gb", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(100)}, }, }, } @@ -142,7 +155,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-schema-fail", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, }, }, } @@ -172,7 +185,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-schema-pass", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, }, }, } @@ -205,7 +218,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-fail", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(32)}, }, }, } @@ -238,7 +251,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-pass", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(8)}, }, }, } @@ -271,8 +284,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-updated", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(4)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, }, }, } @@ -305,8 +318,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-order", UserValues: []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(16)}, - {Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, }, }, } @@ -338,8 +351,8 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "ci-depends-no-key", UserValues: []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(4)}, }, }, } @@ -351,7 +364,7 @@ var _ = Describe("SpecBuilder (via CatalogItemInstance Create)", func() { }) }) -var _ = Describe("BuildResourceSpec (direct)", func() { +var _ = Describe("BuildResourceGraph (single resource)", func() { var ( ctx context.Context db *gorm.DB @@ -388,7 +401,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { Describe("spec construction", func() { It("should return error when catalog item does not exist", func() { - _, err := builder.BuildResourceSpec(ctx, "nonexistent", nil) + _, err := buildGraphSpec(builder, ctx, "nonexistent", nil) Expect(err).To(MatchError(service.ErrCatalogItemNotFoundForInstance)) }) @@ -398,7 +411,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { {Path: "spec.memory.size_gb", Default: float64(8), Editable: false}, }) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-defaults", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-defaults", nil) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -414,7 +427,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { It("should set service_type in the returned spec", func() { ensureCatalogItemWithFields(ctx, str, "ci-direct-st", "vm-d", []model.FieldConfiguration{}) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-st", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-st", nil) Expect(err).ToNot(HaveOccurred()) Expect(result["service_type"]).To(Equal("vm-d")) }) @@ -426,10 +439,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(16)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-override", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-override", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -446,7 +459,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { {Path: "spec.vcpu.count", Default: float64(4), Editable: true}, }) - result, err := builder.BuildResourceSpec(ctx, "ci-direct-preserve", nil) + result, err := buildGraphSpec(builder, ctx, "ci-direct-preserve", nil) Expect(err).ToNot(HaveOccurred()) // disk and memory should remain at ServiceType base values @@ -464,10 +477,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.network.bandwidth", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.network.bandwidth", Value: float64(100)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-badpath", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-badpath", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("user value path not found")) }) @@ -478,10 +491,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.disk.size_gb", Value: float64(100)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(100)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-noedit", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-noedit", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("not editable")) }) @@ -500,7 +513,7 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }, }) - _, err := builder.BuildResourceSpec(ctx, "ci-direct-default-schemafail", nil) + _, err := buildGraphSpec(builder, ctx, "ci-direct-default-schemafail", nil) Expect(err).To(HaveOccurred()) Expect(errors.Is(err, service.ErrFieldDefaultValidationFailed)).To(BeTrue()) Expect(err.Error()).To(ContainSubstring("spec.vcpu.count")) @@ -521,10 +534,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(32)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-schemafail", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-schemafail", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("validation failed")) }) @@ -544,10 +557,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-schemapass", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-schemapass", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -571,10 +584,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(32)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(32)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-depfail", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-depfail", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("depends_on")) }) @@ -596,10 +609,10 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(8)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-deppass", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-deppass", userValues) Expect(err).ToNot(HaveOccurred()) memory := result["memory"].(map[string]any) @@ -623,11 +636,11 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(4)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-depsrc", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-depsrc", userValues) Expect(err).ToNot(HaveOccurred()) vcpu := result["vcpu"].(map[string]any) @@ -653,11 +666,11 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(4)}, } - _, err := builder.BuildResourceSpec(ctx, "ci-direct-depnokey", userValues) + _, err := buildGraphSpec(builder, ctx, "ci-direct-depnokey", userValues) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("no allowed values defined")) }) @@ -670,12 +683,12 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) userValues := []v1alpha1.UserValue{ - {Path: "spec.vcpu.count", Value: float64(8)}, - {Path: "spec.memory.size_gb", Value: float64(16)}, - {Path: "spec.disk.size_gb", Value: float64(200)}, + {Resource: testutil.DefaultResourceName, Path: "spec.vcpu.count", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "spec.memory.size_gb", Value: float64(16)}, + {Resource: testutil.DefaultResourceName, Path: "spec.disk.size_gb", Value: float64(200)}, } - result, err := builder.BuildResourceSpec(ctx, "ci-direct-multi", userValues) + result, err := buildGraphSpec(builder, ctx, "ci-direct-multi", userValues) Expect(err).ToNot(HaveOccurred()) Expect(result["service_type"]).To(Equal("vm-d")) @@ -685,3 +698,83 @@ var _ = Describe("BuildResourceSpec (direct)", func() { }) }) }) + +var _ = Describe("BuildResourceGraph (multi-resource)", func() { + var ( + ctx context.Context + db *gorm.DB + str store.Store + builder *service.SpecBuilder + ) + + BeforeEach(func() { + ctx = context.Background() + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Discard, + }) + Expect(err).ToNot(HaveOccurred()) + err = db.Exec("PRAGMA foreign_keys = ON").Error + Expect(err).ToNot(HaveOccurred()) + err = db.AutoMigrate(&model.ServiceType{}, &model.CatalogItem{}, &model.CatalogItemInstance{}) + Expect(err).ToNot(HaveOccurred()) + str = store.NewStore(db, slog.Default()) + builder = service.NewSpecBuilderForTest(str) + + ensureServiceTypeWithSpec(ctx, str, "db-st", "database", map[string]any{ + "engine": "postgres", + "version": "14", + }) + ensureServiceTypeWithSpec(ctx, str, "ctr-st", "container", map[string]any{ + "image": map[string]any{"reference": "nginx"}, + }) + }) + + AfterEach(func() { + if str != nil { + Expect(str.Close()).To(Succeed()) + } + }) + + It("should resolve multi-resource catalog item with per-resource user values", func() { + ci := model.CatalogItem{ + ID: "dev-app", + ApiVersion: "v1alpha1", + DisplayName: "Dev App", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + { + Name: "ordersDb", + ServiceType: "database", + Fields: []model.FieldConfiguration{ + {Path: "engine", Default: "postgres", Editable: true}, + {Path: "version", Default: "16", Editable: true}, + }, + }, + { + Name: "app", + ServiceType: "container", + RequiresResources: []string{"ordersDb"}, + Fields: []model.FieldConfiguration{ + {Path: "image.reference", Default: "registry.example.com/app:1.0"}, + }, + }, + }, + }, + Path: "catalog-items/dev-app", + } + _, err := str.CatalogItem().Create(ctx, ci) + Expect(err).ToNot(HaveOccurred()) + + resource := "ordersDb" + graph, err := builder.BuildResourceGraph(ctx, "dev-app", []v1alpha1.UserValue{ + {Resource: resource, Path: "version", Value: "17"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(graph).To(HaveLen(2)) + Expect(graph[0].Name).To(Equal("ordersDb")) + Expect(graph[0].Spec["version"]).To(Equal("17")) + Expect(graph[1].Name).To(Equal("app")) + Expect(graph[1].RequiresResources).To(Equal([]string{"ordersDb"})) + }) +}) diff --git a/internal/catalog/store/catalog_item.go b/internal/catalog/store/catalog_item.go index 488d912..d4a2d89 100644 --- a/internal/catalog/store/catalog_item.go +++ b/internal/catalog/store/catalog_item.go @@ -76,7 +76,7 @@ func (s *catalogItemStore) List(ctx context.Context, opts *CatalogItemListOption query = query.Order("id ASC").Limit(pageSize + 1).Offset(offset) if opts != nil && opts.ServiceType != nil && *opts.ServiceType != "" { - query = query.Where("spec_service_type = ?", *opts.ServiceType) + query = applyCatalogItemServiceTypeFilter(query, *opts.ServiceType) } if err := query.Find(&catalogItems).Error; err != nil { @@ -100,7 +100,6 @@ func (s *catalogItemStore) List(ctx context.Context, opts *CatalogItemListOption // Create creates a new catalog item func (s *catalogItemStore) Create(ctx context.Context, catalogItem model.CatalogItem) (*model.CatalogItem, error) { - catalogItem.SpecServiceType = catalogItem.Spec.ServiceType if err := s.db.WithContext(ctx).Clauses(clause.Returning{}).Create(&catalogItem).Error; err != nil { return nil, s.mapConstraintError(ctx, err, catalogItem) } @@ -115,18 +114,6 @@ func (s *catalogItemStore) mapConstraintError(ctx context.Context, err error, at errStr := strings.ToLower(err.Error()) - // Check for foreign key violation first (before checking for generic constraint failed) - if strings.Contains(errStr, "foreign key") { - // Verify which constraint failed by checking if service type exists - var st model.ServiceType - if err := s.db.WithContext(ctx).Where("service_type = ?", attempted.SpecServiceType).First(&st).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrServiceTypeNotFound - } - } - return err - } - // Handle unique constraint violations if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(errStr, "unique") || @@ -158,12 +145,9 @@ func (s *catalogItemStore) Get(ctx context.Context, id string) (*model.CatalogIt // Update updates a catalog item (only mutable fields) func (s *catalogItemStore) Update(ctx context.Context, catalogItem *model.CatalogItem) error { - // Extract service type from spec for denormalized field - catalogItem.SpecServiceType = catalogItem.Spec.ServiceType - result := s.db.WithContext(ctx).Model(&model.CatalogItem{}). Where("id = ?", catalogItem.ID). - Select("display_name", "spec", "spec_service_type"). + Select("display_name", "spec"). Updates(catalogItem) if result.Error != nil { diff --git a/internal/catalog/store/catalog_item_filter.go b/internal/catalog/store/catalog_item_filter.go new file mode 100644 index 0000000..dbbeb4d --- /dev/null +++ b/internal/catalog/store/catalog_item_filter.go @@ -0,0 +1,22 @@ +package store + +import "gorm.io/gorm" + +// applyCatalogItemServiceTypeFilter restricts results to catalog items whose spec.resources +// includes at least one entry with the given service_type. +func applyCatalogItemServiceTypeFilter(query *gorm.DB, serviceType string) *gorm.DB { + switch query.Dialector.Name() { + case "postgres": + return query.Where(`EXISTS ( + SELECT 1 FROM jsonb_array_elements(spec->'resources') AS resource + WHERE resource->>'service_type' = ? + )`, serviceType) + case "sqlite": + return query.Where(`EXISTS ( + SELECT 1 FROM json_each(spec, '$.resources') AS resource + WHERE json_extract(resource.value, '$.service_type') = ? + )`, serviceType) + default: + return query + } +} diff --git a/internal/catalog/store/catalog_item_instance_test.go b/internal/catalog/store/catalog_item_instance_test.go index fa96246..90774dd 100644 --- a/internal/catalog/store/catalog_item_instance_test.go +++ b/internal/catalog/store/catalog_item_instance_test.go @@ -14,6 +14,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("CatalogItemInstance Store", func() { @@ -65,11 +66,8 @@ var _ = Describe("CatalogItemInstance Store", func() { ID: id, ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Test %s", id), - Spec: model.CatalogItemSpec{ - ServiceType: serviceType, - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/%s", id), + Spec: testutil.ModelCatalogSpec(serviceType, []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/%s", id), } _, err := catalogItemStore.Create(context.Background(), ci) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/store/catalog_item_test.go b/internal/catalog/store/catalog_item_test.go index 66dc92b..7463ba7 100644 --- a/internal/catalog/store/catalog_item_test.go +++ b/internal/catalog/store/catalog_item_test.go @@ -14,6 +14,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("CatalogItem Store", func() { @@ -72,16 +73,13 @@ var _ = Describe("CatalogItem Store", func() { ID: "small-vm", ApiVersion: "v1alpha1", DisplayName: "Small VM", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{ - { - Path: "spec.vcpu.count", - Editable: false, - Default: 2, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{ + { + Path: "spec.vcpu.count", + Editable: false, + Default: 2, }, - }, + }), Path: "catalog-items/small-vm", } @@ -93,8 +91,7 @@ var _ = Describe("CatalogItem Store", func() { Expect(err).ToNot(HaveOccurred()) Expect(retrieved.ID).To(Equal("small-vm")) Expect(retrieved.DisplayName).To(Equal("Small VM")) - Expect(retrieved.Spec.ServiceType).To(Equal("vm")) - Expect(retrieved.SpecServiceType).To(Equal("vm")) + Expect(retrieved.Spec.Resources[0].ServiceType).To(Equal("vm")) }) It("should return error when creating duplicate ID", func() { @@ -105,11 +102,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "duplicate-ci", ApiVersion: "v1alpha1", DisplayName: "Original", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/duplicate-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/duplicate-ci", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -120,33 +114,14 @@ var _ = Describe("CatalogItem Store", func() { ID: "duplicate-ci", ApiVersion: "v1alpha1", DisplayName: "Duplicate", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/duplicate-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/duplicate-ci", } _, err = catalogItemStore.Create(context.Background(), ci2) Expect(err).To(Equal(store.ErrCatalogItemIDTaken)) }) - It("should return error when creating with non-existent service type", func() { - ci := &model.CatalogItem{ - ID: "invalid-st-ci", - ApiVersion: "v1alpha1", - DisplayName: "Invalid Service Type", - Spec: model.CatalogItemSpec{ - ServiceType: "non-existent-service-type", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/invalid-st-ci", - } - - _, err := catalogItemStore.Create(context.Background(), *ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should create catalog item with valid service type", func() { // Create prerequisite service type createTestServiceType("valid-st", "valid-service") @@ -155,11 +130,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "valid-ci", ApiVersion: "v1alpha1", DisplayName: "Valid Catalog Item", - Spec: model.CatalogItemSpec{ - ServiceType: "valid-service", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/valid-ci", + Spec: testutil.ModelCatalogSpec("valid-service", []model.FieldConfiguration{}), + Path: "catalog-items/valid-ci", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -176,12 +148,9 @@ var _ = Describe("CatalogItem Store", func() { ID: "get-test-ci", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "database", - Fields: []model.FieldConfiguration{ - {Path: "spec.engine", Default: "postgres"}, - }, - }, + Spec: testutil.ModelCatalogSpec("database", []model.FieldConfiguration{ + {Path: "spec.engine", Default: "postgres"}, + }), Path: "catalog-items/get-test-ci", } @@ -191,7 +160,7 @@ var _ = Describe("CatalogItem Store", func() { retrieved, err := catalogItemStore.Get(context.Background(), "get-test-ci") Expect(err).ToNot(HaveOccurred()) Expect(retrieved.ID).To(Equal("get-test-ci")) - Expect(retrieved.Spec.ServiceType).To(Equal("database")) + Expect(retrieved.Spec.Resources[0].ServiceType).To(Equal("database")) }) It("should return error for non-existent catalog item", func() { @@ -209,12 +178,9 @@ var _ = Describe("CatalogItem Store", func() { ID: "update-test", ApiVersion: "v1alpha1", DisplayName: "Original Name", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{ - {Path: "spec.vcpu.count", Default: 2}, - }, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{ + {Path: "spec.vcpu.count", Default: 2}, + }), Path: "catalog-items/update-test", } @@ -224,7 +190,7 @@ var _ = Describe("CatalogItem Store", func() { // Update mutable fields ci.DisplayName = "Updated Name" - ci.Spec.Fields = append(ci.Spec.Fields, model.FieldConfiguration{ + ci.Spec.Resources[0].Fields = append(ci.Spec.Resources[0].Fields, model.FieldConfiguration{ Path: "spec.memory.size_gb", Default: 8, }) @@ -236,7 +202,7 @@ var _ = Describe("CatalogItem Store", func() { retrieved, err := catalogItemStore.Get(context.Background(), "update-test") Expect(err).ToNot(HaveOccurred()) Expect(retrieved.DisplayName).To(Equal("Updated Name")) - Expect(retrieved.Spec.Fields).To(HaveLen(2)) + Expect(retrieved.Spec.Resources[0].Fields).To(HaveLen(2)) }) It("should not update immutable fields", func() { @@ -249,11 +215,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "immutable-update-test", ApiVersion: originalApiVersion, DisplayName: "Original Name", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: originalPath, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: originalPath, } created, err := catalogItemStore.Create(context.Background(), *ci) @@ -277,62 +240,25 @@ var _ = Describe("CatalogItem Store", func() { }) It("should return error when updating non-existent catalog item", func() { - // Create prerequisite service type - createTestServiceType("vm-st-nonexist", "vm") - ci := &model.CatalogItem{ ID: "non-existent", DisplayName: "Updated", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), } err := catalogItemStore.Update(context.Background(), ci) Expect(err).To(Equal(store.ErrCatalogItemNotFound)) }) - - It("should return error when updating with non-existent service type", func() { - // Create prerequisite service types - createTestServiceType("vm-st-orig", "vm") - - ci := &model.CatalogItem{ - ID: "update-invalid-st", - ApiVersion: "v1alpha1", - DisplayName: "Original", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/update-invalid-st", - } - - created, err := catalogItemStore.Create(context.Background(), *ci) - Expect(err).ToNot(HaveOccurred()) - ci = created - - // Try to update with non-existent service type - ci.Spec.ServiceType = "non-existent-service-type" - err = catalogItemStore.Update(context.Background(), ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) }) Describe("Delete", func() { It("should delete an existing catalog item", func() { - // Create prerequisite service type - createTestServiceType("vm-st-del", "vm") - ci := &model.CatalogItem{ ID: "delete-test", ApiVersion: "v1alpha1", DisplayName: "To Delete", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/delete-test", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/delete-test", } _, err := catalogItemStore.Create(context.Background(), *ci) @@ -373,11 +299,8 @@ var _ = Describe("CatalogItem Store", func() { ID: fmt.Sprintf("ci-%d", i), ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/ci-%d", i), + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/ci-%d", i), } time.Sleep(time.Millisecond) _, err := catalogItemStore.Create(context.Background(), ci) @@ -400,11 +323,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "vm-item", ApiVersion: "v1alpha1", DisplayName: "VM Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/vm-item", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/vm-item", } _, err := catalogItemStore.Create(context.Background(), ci1) Expect(err).ToNot(HaveOccurred()) @@ -413,11 +333,8 @@ var _ = Describe("CatalogItem Store", func() { ID: "db-item", ApiVersion: "v1alpha1", DisplayName: "DB Item", - Spec: model.CatalogItemSpec{ - ServiceType: "database", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/db-item", + Spec: testutil.ModelCatalogSpec("database", []model.FieldConfiguration{}), + Path: "catalog-items/db-item", } _, err = catalogItemStore.Create(context.Background(), ci2) Expect(err).ToNot(HaveOccurred()) @@ -427,14 +344,14 @@ var _ = Describe("CatalogItem Store", func() { result, err := catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{PageSize: 100, ServiceType: &serviceTypeVM}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(result.CatalogItems[0].Spec.ServiceType).To(Equal("vm")) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal("vm")) // Filter for database service type serviceTypeDB := "database" result, err = catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{PageSize: 100, ServiceType: &serviceTypeDB}) Expect(err).ToNot(HaveOccurred()) Expect(result.CatalogItems).To(HaveLen(1)) - Expect(result.CatalogItems[0].Spec.ServiceType).To(Equal("database")) + Expect(result.CatalogItems[0].Spec.Resources[0].ServiceType).To(Equal("database")) // Filter for non-existent service type serviceTypeNonExistent := "non-existent" @@ -443,6 +360,42 @@ var _ = Describe("CatalogItem Store", func() { Expect(result.CatalogItems).To(BeEmpty()) }) + It("should filter multi-resource items when any resource matches", func() { + ci1 := model.CatalogItem{ + ID: "vm-only", + ApiVersion: "v1alpha1", + DisplayName: "VM Only", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/vm-only", + } + _, err := catalogItemStore.Create(context.Background(), ci1) + Expect(err).ToNot(HaveOccurred()) + + ci2 := model.CatalogItem{ + ID: "dev-app", + ApiVersion: "v1alpha1", + DisplayName: "Dev App", + Spec: model.CatalogItemSpec{ + Resources: []model.CatalogResource{ + {Name: "ordersDb", ServiceType: "database"}, + {Name: "app", ServiceType: "container"}, + }, + }, + Path: "catalog-items/dev-app", + } + _, err = catalogItemStore.Create(context.Background(), ci2) + Expect(err).ToNot(HaveOccurred()) + + containerFilter := "container" + result, err := catalogItemStore.List(context.Background(), &store.CatalogItemListOptions{ + PageSize: 100, + ServiceType: &containerFilter, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(result.CatalogItems).To(HaveLen(1)) + Expect(result.CatalogItems[0].ID).To(Equal("dev-app")) + }) + It("should handle pagination correctly", func() { // Create prerequisite service type createTestServiceType("vm-st-page", "vm") @@ -453,11 +406,8 @@ var _ = Describe("CatalogItem Store", func() { ID: fmt.Sprintf("page-ci-%d", i), ApiVersion: "v1alpha1", DisplayName: fmt.Sprintf("Item %d", i), - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: fmt.Sprintf("catalog-items/page-ci-%d", i), + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: fmt.Sprintf("catalog-items/page-ci-%d", i), } time.Sleep(time.Millisecond) _, err := catalogItemStore.Create(context.Background(), ci) diff --git a/internal/catalog/store/integration_test.go b/internal/catalog/store/integration_test.go index 0be5b69..817f4e2 100644 --- a/internal/catalog/store/integration_test.go +++ b/internal/catalog/store/integration_test.go @@ -12,6 +12,7 @@ import ( "github.com/dcm-project/control-plane/internal/catalog/store" "github.com/dcm-project/control-plane/internal/catalog/store/model" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) var _ = Describe("Foreign Key Constraint Integration Tests", func() { @@ -69,11 +70,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "small-vm", ApiVersion: "v1alpha1", DisplayName: "Small VM", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/small-vm", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/small-vm", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -99,7 +97,7 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { retrievedCI, err := catalogItemStore.Get(ctx, "small-vm") Expect(err).ToNot(HaveOccurred()) - Expect(retrievedCI.Spec.ServiceType).To(Equal("vm")) + Expect(retrievedCI.Spec.Resources[0].ServiceType).To(Equal("vm")) retrievedCII, err := catalogItemInstanceStore.Get(ctx, "my-vm") Expect(err).ToNot(HaveOccurred()) @@ -108,23 +106,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { }) Describe("Foreign Key Violation Detection", func() { - It("should prevent creating CatalogItem with non-existent ServiceType", func() { - ctx := context.Background() - - ci := model.CatalogItem{ - ID: "invalid-ci", - ApiVersion: "v1alpha1", - DisplayName: "Invalid Item", - Spec: model.CatalogItemSpec{ - ServiceType: "non-existent", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/invalid-ci", - } - _, err := catalogItemStore.Create(ctx, ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should prevent creating CatalogItemInstance with non-existent CatalogItem", func() { ctx := context.Background() @@ -142,39 +123,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { Expect(err).To(Equal(store.ErrCatalogItemNotFoundRef)) }) - It("should prevent updating CatalogItem to non-existent ServiceType", func() { - ctx := context.Background() - - // Create valid hierarchy first - st := model.ServiceType{ - ID: "vm-st", - ApiVersion: "v1alpha1", - ServiceType: "vm", - Spec: map[string]any{}, - Path: "service-types/vm-st", - } - _, err := serviceTypeStore.Create(ctx, st) - Expect(err).ToNot(HaveOccurred()) - - ci := model.CatalogItem{ - ID: "test-ci", - ApiVersion: "v1alpha1", - DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci", - } - created, err := catalogItemStore.Create(ctx, ci) - Expect(err).ToNot(HaveOccurred()) - - // Try to update to non-existent service type - created.Spec.ServiceType = "non-existent" - err = catalogItemStore.Update(ctx, created) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - }) - It("should prevent updating CatalogItemInstance to non-existent CatalogItem", func() { ctx := context.Background() @@ -193,11 +141,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-update", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-update", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-update", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -241,11 +186,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-del", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-del", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-del", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -294,11 +236,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "test-ci-del-no-inst", ApiVersion: "v1alpha1", DisplayName: "Test Item", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/test-ci-del-no-inst", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/test-ci-del-no-inst", } _, err = catalogItemStore.Create(ctx, ci) Expect(err).ToNot(HaveOccurred()) @@ -317,20 +256,6 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { It("should return correct error for each violation type", func() { ctx := context.Background() - // Test ErrServiceTypeNotFound - ci := model.CatalogItem{ - ID: "err-test-1", - ApiVersion: "v1alpha1", - DisplayName: "Error Test 1", - Spec: model.CatalogItemSpec{ - ServiceType: "missing-st", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/err-test-1", - } - _, err := catalogItemStore.Create(ctx, ci) - Expect(err).To(Equal(store.ErrServiceTypeNotFound)) - // Test ErrCatalogItemNotFoundRef cii := model.CatalogItemInstance{ ID: "err-test-2", @@ -342,7 +267,7 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { }, Path: "catalog-item-instances/err-test-2", } - _, err = catalogItemInstanceStore.Create(ctx, cii) + _, err := catalogItemInstanceStore.Create(ctx, cii) Expect(err).To(Equal(store.ErrCatalogItemNotFoundRef)) // Test ErrCatalogItemHasInstances @@ -361,11 +286,8 @@ var _ = Describe("Foreign Key Constraint Integration Tests", func() { ID: "err-test-ci", ApiVersion: "v1alpha1", DisplayName: "Error Test CI", - Spec: model.CatalogItemSpec{ - ServiceType: "vm", - Fields: []model.FieldConfiguration{}, - }, - Path: "catalog-items/err-test-ci", + Spec: testutil.ModelCatalogSpec("vm", []model.FieldConfiguration{}), + Path: "catalog-items/err-test-ci", } _, err = catalogItemStore.Create(ctx, ci2) Expect(err).ToNot(HaveOccurred()) diff --git a/internal/catalog/store/model/catalog_item.go b/internal/catalog/store/model/catalog_item.go index b2cc81f..f0329be 100644 --- a/internal/catalog/store/model/catalog_item.go +++ b/internal/catalog/store/model/catalog_item.go @@ -14,19 +14,37 @@ type CatalogItem struct { Path string `gorm:"column:path;not null"` CreateTime time.Time `gorm:"column:create_time;autoCreateTime"` UpdateTime time.Time `gorm:"column:update_time;autoUpdateTime"` - - // Indexed field for filtering - SpecServiceType string `gorm:"column:spec_service_type;not null;index"` - ServiceTypeRef *ServiceType `gorm:"foreignKey:SpecServiceType;references:ServiceType;constraint:OnDelete:RESTRICT"` } // CatalogItemList is a slice of CatalogItem for list results type CatalogItemList []CatalogItem -// CatalogItemSpec represents the spec field of a catalog item +// CatalogItemSpec represents the spec field of a catalog item. type CatalogItemSpec struct { - ServiceType string `json:"service_type"` - Fields []FieldConfiguration `json:"fields"` + Resources []CatalogResource `json:"resources"` +} + +// IsMultiResource returns true when the catalog item defines more than one resource. +func (s CatalogItemSpec) IsMultiResource() bool { + return len(s.Resources) > 1 +} + +// HasResourceServiceType reports whether any resource uses the given service type. +func (s CatalogItemSpec) HasResourceServiceType(serviceType string) bool { + for _, r := range s.Resources { + if r.ServiceType == serviceType { + return true + } + } + return false +} + +// CatalogResource is a named resource within a catalog item. +type CatalogResource struct { + Name string `json:"name"` + ServiceType string `json:"service_type"` + RequiresResources []string `json:"requires_resources,omitempty"` + Fields []FieldConfiguration `json:"fields,omitempty"` } // DependsOn defines conditional default based on another field's value diff --git a/internal/catalog/store/model/catalog_item_instance.go b/internal/catalog/store/model/catalog_item_instance.go index 16302c6..1c53bde 100644 --- a/internal/catalog/store/model/catalog_item_instance.go +++ b/internal/catalog/store/model/catalog_item_instance.go @@ -27,10 +27,13 @@ type CatalogItemInstanceList []CatalogItemInstance type CatalogItemInstanceSpec struct { CatalogItemId string `json:"catalog_item_id"` UserValues []UserValue `json:"user_values"` + // ResourceIDs stores placement resource IDs for multi-resource instances (all nodes). + ResourceIDs []string `json:"resource_ids,omitempty"` } // UserValue represents a user-provided value for a field type UserValue struct { - Path string `json:"path"` - Value any `json:"value"` + Resource string `json:"resource,omitempty"` + Path string `json:"path"` + Value any `json:"value"` } diff --git a/internal/catalog/testutil/catalog_spec.go b/internal/catalog/testutil/catalog_spec.go new file mode 100644 index 0000000..45b1bc1 --- /dev/null +++ b/internal/catalog/testutil/catalog_spec.go @@ -0,0 +1,63 @@ +// Package testutil provides helpers shared by catalog tests. +package testutil + +import ( + "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/store/model" +) + +// DefaultResourceName is the single-resource name used by most catalog tests. +const DefaultResourceName = "main" + +func ptrAPIFields(fields []v1alpha1.FieldConfiguration) *[]v1alpha1.FieldConfiguration { + return &fields +} + +// CatalogSpec builds a single-resource CatalogItemSpec for API-layer tests. +func CatalogSpec(serviceType string, fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return v1alpha1.CatalogItemSpec{ + Resources: []v1alpha1.CatalogResource{{ + Name: DefaultResourceName, + ServiceType: serviceType, + Fields: ptrAPIFields(fields), + }}, + } +} + +// CatalogSpecVM builds a single-resource VM catalog item spec. +func CatalogSpecVM(fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return CatalogSpec("vm", fields) +} + +// CatalogSpecContainer builds a single-resource container catalog item spec. +func CatalogSpecContainer(fields []v1alpha1.FieldConfiguration) v1alpha1.CatalogItemSpec { + return CatalogSpec("container", fields) +} + +// PtrCatalogSpec returns a pointer to CatalogSpec. +func PtrCatalogSpec(serviceType string, fields []v1alpha1.FieldConfiguration) *v1alpha1.CatalogItemSpec { + s := CatalogSpec(serviceType, fields) + return &s +} + +// PtrCatalogSpecVM returns a pointer to CatalogSpecVM. +func PtrCatalogSpecVM(fields []v1alpha1.FieldConfiguration) *v1alpha1.CatalogItemSpec { + return PtrCatalogSpec("vm", fields) +} + +// ModelCatalogSpec builds a single-resource CatalogItemSpec for store-layer tests. +func ModelCatalogSpec(serviceType string, fields []model.FieldConfiguration) model.CatalogItemSpec { + return model.CatalogItemSpec{ + Resources: []model.CatalogResource{{ + Name: DefaultResourceName, + ServiceType: serviceType, + Fields: fields, + }}, + } +} + +// PtrModelCatalogSpec returns a pointer to ModelCatalogSpec. +func PtrModelCatalogSpec(serviceType string, fields []model.FieldConfiguration) *model.CatalogItemSpec { + s := ModelCatalogSpec(serviceType, fields) + return &s +} diff --git a/test/subsystem/catalog/catalog_item_instance_test.go b/test/subsystem/catalog/catalog_item_instance_test.go index f14f01a..8ea1e81 100644 --- a/test/subsystem/catalog/catalog_item_instance_test.go +++ b/test/subsystem/catalog/catalog_item_instance_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -9,6 +10,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" "github.com/google/uuid" ) @@ -54,7 +56,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "vcpu.count", Value: float64(4)}, + {Resource: testutil.DefaultResourceName, Path: "vcpu.count", Value: float64(4)}, }, }, } @@ -119,7 +121,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "nonexistent.path", Value: "bad"}, + {Resource: testutil.DefaultResourceName, Path: "nonexistent.path", Value: "bad"}, }, }, } @@ -139,7 +141,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "memory.size_gb", Value: float64(8)}, + {Resource: testutil.DefaultResourceName, Path: "memory.size_gb", Value: float64(8)}, }, }, } @@ -159,7 +161,7 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: catalogItemID, UserValues: []v1alpha1.UserValue{ - {Path: "vcpu.count", Value: float64(99)}, // exceeds maximum of 16 + {Resource: testutil.DefaultResourceName, Path: "vcpu.count", Value: float64(99)}, // exceeds maximum of 16 }, }, } @@ -179,8 +181,8 @@ var _ = Describe("CatalogItemInstance API", func() { Spec: v1alpha1.CatalogItemInstanceSpec{ CatalogItemId: "pet-clinic", UserValues: []v1alpha1.UserValue{ - {Path: "database.engine", Value: "postgres"}, - {Path: "database.version", Value: "8.4"}, // 8.4 is only allowed for mysql, not postgres + {Resource: "app", Path: "database.engine", Value: "postgres"}, + {Resource: "app", Path: "database.version", Value: "8.4"}, // 8.4 is only allowed for mysql, not postgres }, }, } diff --git a/test/subsystem/catalog/catalog_item_test.go b/test/subsystem/catalog/catalog_item_test.go index 691293c..0ccb63c 100644 --- a/test/subsystem/catalog/catalog_item_test.go +++ b/test/subsystem/catalog/catalog_item_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -9,6 +10,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" "github.com/google/uuid" ) @@ -29,7 +31,7 @@ var _ = Describe("CatalogItem API", func() { Expect(item.DisplayName).NotTo(BeNil()) Expect(*item.DisplayName).To(Equal("Test Item")) Expect(item.Spec).NotTo(BeNil()) - Expect(*item.Spec.ServiceType).To(Equal("vm")) + Expect(item.Spec.Resources[0].ServiceType).To(Equal("vm")) }) It("uses user-specified ID when provided", func() { @@ -45,14 +47,10 @@ var _ = Describe("CatalogItem API", func() { createTestCatalogItem(id, "First", "vm", nil) params := &v1alpha1.CreateCatalogItemParams{Id: &id} - fields := []v1alpha1.FieldConfiguration{defaultField()} body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: stringPtr("Second"), - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("vm"), - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec("vm", []v1alpha1.FieldConfiguration{defaultField()}), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) @@ -63,14 +61,10 @@ var _ = Describe("CatalogItem API", func() { It("returns 400 for non-existent service type", func() { id := "ci-badst-" + uuid.NewString()[:8] params := &v1alpha1.CreateCatalogItemParams{Id: &id} - fields := []v1alpha1.FieldConfiguration{defaultField()} body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: stringPtr("Bad ST"), - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("nonexistent-service-type"), - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec("nonexistent-service-type", nil), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) Expect(err).NotTo(HaveOccurred()) @@ -129,7 +123,7 @@ var _ = Describe("CatalogItem API", func() { Expect(resp.JSON200).NotTo(BeNil()) for _, item := range resp.JSON200.Results { - Expect(*item.Spec.ServiceType).To(Equal("database")) + Expect(item.Spec.Resources[0].ServiceType).To(Equal("database")) } uids := make([]string, len(resp.JSON200.Results)) for i, item := range resp.JSON200.Results { @@ -177,9 +171,7 @@ var _ = Describe("CatalogItem API", func() { createTestCatalogItem(id, "Immutable ST", "vm", nil) updateBody := v1alpha1.CatalogItem{ - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: stringPtr("database"), - }, + Spec: testutil.PtrCatalogSpec("database", nil), } resp, err := apiClient.UpdateCatalogItemWithApplicationMergePatchPlusJSONBodyWithResponse( context.Background(), id, updateBody, diff --git a/test/subsystem/catalog/setup_test.go b/test/subsystem/catalog/setup_test.go index 9bc48d1..2a1b0ca 100644 --- a/test/subsystem/catalog/setup_test.go +++ b/test/subsystem/catalog/setup_test.go @@ -1,4 +1,5 @@ //go:build subsystem + package subsystem_test import ( @@ -12,6 +13,7 @@ import ( . "github.com/onsi/gomega" v1alpha1 "github.com/dcm-project/control-plane/api/catalog/v1alpha1" + "github.com/dcm-project/control-plane/internal/catalog/testutil" ) // --- WireMock helpers --- @@ -28,7 +30,7 @@ func resetWireMock() { func stubPMCreateResource() { stub := map[string]any{ "request": map[string]any{ - "method": "POST", + "method": "POST", "urlPattern": "/api/v1alpha1/resources.*", }, "response": map[string]any{ @@ -91,7 +93,7 @@ func stubPMRehydrateResourceFailure() { func stubPMDeleteResource() { stub := map[string]any{ "request": map[string]any{ - "method": "DELETE", + "method": "DELETE", "urlPathPattern": "/api/v1alpha1/resources/.*", }, "response": map[string]any{ @@ -192,7 +194,7 @@ func stubPMRehydrateResourceProviderError() { func stubPMCreateResourceFailure() { stub := map[string]any{ "request": map[string]any{ - "method": "POST", + "method": "POST", "urlPattern": "/api/v1alpha1/resources.*", }, "response": map[string]any{ @@ -213,7 +215,7 @@ func stubPMCreateResourceFailure() { func stubPMDeleteResourceFailure() { stub := map[string]any{ "request": map[string]any{ - "method": "DELETE", + "method": "DELETE", "urlPathPattern": "/api/v1alpha1/resources/.*", }, "response": map[string]any{ @@ -312,10 +314,7 @@ func createTestCatalogItem(id, displayName, serviceType string, fields []v1alpha body := v1alpha1.CatalogItem{ ApiVersion: stringPtr("v1alpha1"), DisplayName: &displayName, - Spec: &v1alpha1.CatalogItemSpec{ - ServiceType: &serviceType, - Fields: &fields, - }, + Spec: testutil.PtrCatalogSpec(serviceType, fields), } resp, err := apiClient.CreateCatalogItemWithResponse(context.Background(), params, body) ExpectWithOffset(1, err).NotTo(HaveOccurred())