From 4e265aa9dfd71e99258a95a800064c66b7867aff Mon Sep 17 00:00:00 2001 From: Elad Tabak Date: Mon, 11 May 2026 16:41:01 +0300 Subject: [PATCH] OSAC-58: add private CRUD servers for catalog items - Add private ClusterCatalogItems and ComputeInstanceCatalogItems servers using GenericServer pattern (full CRUD + Signal) - Register both servers in gRPC startup and REST gateway - Add setPayload switch cases for event notification - Add unit tests for both servers (builder validation + CRUD behavior + FieldDefinition round-trip persistence) Generated with [Claude Code](https://claude.com/claude-code) --- .../start/grpcserver/start_grpc_server_cmd.go | 28 ++ .../restgateway/start_rest_gateway_cmd.go | 8 + internal/servers/generic_server.go | 4 + .../private_cluster_catalog_items_server.go | 137 ++++++ ...ivate_cluster_catalog_items_server_test.go | 389 ++++++++++++++++++ ...e_compute_instance_catalog_items_server.go | 137 ++++++ ...pute_instance_catalog_items_server_test.go | 388 +++++++++++++++++ 7 files changed, 1091 insertions(+) create mode 100644 internal/servers/private_cluster_catalog_items_server.go create mode 100644 internal/servers/private_cluster_catalog_items_server_test.go create mode 100644 internal/servers/private_compute_instance_catalog_items_server.go create mode 100644 internal/servers/private_compute_instance_catalog_items_server_test.go diff --git a/internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go b/internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go index 9315b253e..2e57691cc 100644 --- a/internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go +++ b/internal/cmd/service/start/grpcserver/start_grpc_server_cmd.go @@ -439,6 +439,34 @@ func (c *runnerContext) run(cmd *cobra.Command, argv []string) error { } privatev1.RegisterClusterTemplatesServer(grpcServer, privateClusterTemplatesServer) + // Create the private cluster catalog items server: + c.logger.InfoContext(ctx, "Creating private cluster catalog items server") + privateClusterCatalogItemsServer, err := servers.NewPrivateClusterCatalogItemsServer(). + SetLogger(c.logger). + SetNotifier(notifier). + SetAttributionLogic(privateAttributionLogic). + SetTenancyLogic(tenancyLogic). + SetMetricsRegisterer(metricsRegisterer). + Build() + if err != nil { + return fmt.Errorf("failed to create private cluster catalog items server: %w", err) + } + privatev1.RegisterClusterCatalogItemsServer(grpcServer, privateClusterCatalogItemsServer) + + // Create the private compute instance catalog items server: + c.logger.InfoContext(ctx, "Creating private compute instance catalog items server") + privateComputeInstanceCatalogItemsServer, err := servers.NewPrivateComputeInstanceCatalogItemsServer(). + SetLogger(c.logger). + SetNotifier(notifier). + SetAttributionLogic(privateAttributionLogic). + SetTenancyLogic(tenancyLogic). + SetMetricsRegisterer(metricsRegisterer). + Build() + if err != nil { + return fmt.Errorf("failed to create private compute instance catalog items server: %w", err) + } + privatev1.RegisterComputeInstanceCatalogItemsServer(grpcServer, privateComputeInstanceCatalogItemsServer) + // Create the runtime scheme for typed OSAC API objects: hubScheme, err := hubscheme.NewHub() if err != nil { diff --git a/internal/cmd/service/start/restgateway/start_rest_gateway_cmd.go b/internal/cmd/service/start/restgateway/start_rest_gateway_cmd.go index e245cadad..340907c8e 100644 --- a/internal/cmd/service/start/restgateway/start_rest_gateway_cmd.go +++ b/internal/cmd/service/start/restgateway/start_rest_gateway_cmd.go @@ -211,6 +211,10 @@ func (c *runnerContext) run(cmd *cobra.Command, argv []string) error { if err != nil { return err } + err = privatev1.RegisterClusterCatalogItemsHandler(ctx, gatewayMux, c.grpcClient) + if err != nil { + return err + } err = privatev1.RegisterClustersHandler(ctx, gatewayMux, c.grpcClient) if err != nil { return err @@ -231,6 +235,10 @@ func (c *runnerContext) run(cmd *cobra.Command, argv []string) error { if err != nil { return err } + err = privatev1.RegisterComputeInstanceCatalogItemsHandler(ctx, gatewayMux, c.grpcClient) + if err != nil { + return err + } err = privatev1.RegisterComputeInstancesHandler(ctx, gatewayMux, c.grpcClient) if err != nil { return err diff --git a/internal/servers/generic_server.go b/internal/servers/generic_server.go index f744b4490..ccd18263f 100644 --- a/internal/servers/generic_server.go +++ b/internal/servers/generic_server.go @@ -856,6 +856,10 @@ func (s *GenericServer[O]) setPayload(event *privatev1.Event, object proto.Messa event.SetRoleBinding(object) case *privatev1.Project: event.SetProject(object) + case *privatev1.ClusterCatalogItem: + event.SetClusterCatalogItem(object) + case *privatev1.ComputeInstanceCatalogItem: + event.SetComputeInstanceCatalogItem(object) default: return fmt.Errorf("unknown object type '%T'", object) } diff --git a/internal/servers/private_cluster_catalog_items_server.go b/internal/servers/private_cluster_catalog_items_server.go new file mode 100644 index 000000000..6191aa7c0 --- /dev/null +++ b/internal/servers/private_cluster_catalog_items_server.go @@ -0,0 +1,137 @@ +/* +Copyright (c) 2025 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package servers + +import ( + "context" + "errors" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/auth" + "github.com/osac-project/fulfillment-service/internal/database" +) + +type PrivateClusterCatalogItemsServerBuilder struct { + logger *slog.Logger + notifier *database.Notifier + attributionLogic auth.AttributionLogic + tenancyLogic auth.TenancyLogic + metricsRegisterer prometheus.Registerer +} + +var _ privatev1.ClusterCatalogItemsServer = (*PrivateClusterCatalogItemsServer)(nil) + +type PrivateClusterCatalogItemsServer struct { + privatev1.UnimplementedClusterCatalogItemsServer + logger *slog.Logger + generic *GenericServer[*privatev1.ClusterCatalogItem] +} + +func NewPrivateClusterCatalogItemsServer() *PrivateClusterCatalogItemsServerBuilder { + return &PrivateClusterCatalogItemsServerBuilder{} +} + +func (b *PrivateClusterCatalogItemsServerBuilder) SetLogger(value *slog.Logger) *PrivateClusterCatalogItemsServerBuilder { + b.logger = value + return b +} + +func (b *PrivateClusterCatalogItemsServerBuilder) SetNotifier( + value *database.Notifier) *PrivateClusterCatalogItemsServerBuilder { + b.notifier = value + return b +} + +func (b *PrivateClusterCatalogItemsServerBuilder) SetAttributionLogic(value auth.AttributionLogic) *PrivateClusterCatalogItemsServerBuilder { + b.attributionLogic = value + return b +} + +func (b *PrivateClusterCatalogItemsServerBuilder) SetTenancyLogic(value auth.TenancyLogic) *PrivateClusterCatalogItemsServerBuilder { + b.tenancyLogic = value + return b +} + +func (b *PrivateClusterCatalogItemsServerBuilder) SetMetricsRegisterer(value prometheus.Registerer) *PrivateClusterCatalogItemsServerBuilder { + b.metricsRegisterer = value + return b +} + +func (b *PrivateClusterCatalogItemsServerBuilder) Build() (result *PrivateClusterCatalogItemsServer, err error) { + if b.logger == nil { + err = errors.New("logger is mandatory") + return + } + if b.tenancyLogic == nil { + err = errors.New("tenancy logic is mandatory") + return + } + + generic, err := NewGenericServer[*privatev1.ClusterCatalogItem](). + SetLogger(b.logger). + SetService(privatev1.ClusterCatalogItems_ServiceDesc.ServiceName). + SetNotifier(b.notifier). + SetAttributionLogic(b.attributionLogic). + SetTenancyLogic(b.tenancyLogic). + SetMetricsRegisterer(b.metricsRegisterer). + Build() + if err != nil { + return + } + + result = &PrivateClusterCatalogItemsServer{ + logger: b.logger, + generic: generic, + } + return +} + +func (s *PrivateClusterCatalogItemsServer) List(ctx context.Context, + request *privatev1.ClusterCatalogItemsListRequest) (response *privatev1.ClusterCatalogItemsListResponse, err error) { + err = s.generic.List(ctx, request, &response) + return +} + +func (s *PrivateClusterCatalogItemsServer) Get(ctx context.Context, + request *privatev1.ClusterCatalogItemsGetRequest) (response *privatev1.ClusterCatalogItemsGetResponse, err error) { + err = s.generic.Get(ctx, request, &response) + return +} + +func (s *PrivateClusterCatalogItemsServer) Create(ctx context.Context, + request *privatev1.ClusterCatalogItemsCreateRequest) (response *privatev1.ClusterCatalogItemsCreateResponse, err error) { + err = s.generic.Create(ctx, request, &response) + return +} + +func (s *PrivateClusterCatalogItemsServer) Update(ctx context.Context, + request *privatev1.ClusterCatalogItemsUpdateRequest) (response *privatev1.ClusterCatalogItemsUpdateResponse, err error) { + err = s.generic.Update(ctx, request, &response) + return +} + +func (s *PrivateClusterCatalogItemsServer) Delete(ctx context.Context, + request *privatev1.ClusterCatalogItemsDeleteRequest) (response *privatev1.ClusterCatalogItemsDeleteResponse, err error) { + err = s.generic.Delete(ctx, request, &response) + return +} + +func (s *PrivateClusterCatalogItemsServer) Signal(ctx context.Context, + request *privatev1.ClusterCatalogItemsSignalRequest) (response *privatev1.ClusterCatalogItemsSignalResponse, err error) { + err = s.generic.Signal(ctx, request, &response) + return +} diff --git a/internal/servers/private_cluster_catalog_items_server_test.go b/internal/servers/private_cluster_catalog_items_server_test.go new file mode 100644 index 000000000..6a3cc9154 --- /dev/null +++ b/internal/servers/private_cluster_catalog_items_server_test.go @@ -0,0 +1,389 @@ +/* +Copyright (c) 2025 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package servers + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/database" + "github.com/osac-project/fulfillment-service/internal/database/dao" +) + +var _ = Describe("Private cluster catalog items server", func() { + var ( + ctx context.Context + tx database.Tx + ) + + BeforeEach(func() { + var err error + + // Create a context: + ctx = context.Background() + + // Prepare the database pool: + db := server.MakeDatabase() + DeferCleanup(db.Close) + pool, err := pgxpool.New(ctx, db.MakeURL()) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(pool.Close) + + // Create the transaction manager: + tm, err := database.NewTxManager(). + SetLogger(logger). + SetPool(pool). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Start a transaction and add it to the context: + tx, err = tm.Begin(ctx) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + err := tm.End(ctx, tx) + Expect(err).ToNot(HaveOccurred()) + }) + ctx = database.TxIntoContext(ctx, tx) + + // Create the tables: + err = dao.CreateTables[*privatev1.ClusterCatalogItem](ctx) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Creation", func() { + It("Can be built if all the required parameters are set", func() { + server, err := NewPrivateClusterCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + Expect(server).ToNot(BeNil()) + }) + + It("Fails if logger is not set", func() { + server, err := NewPrivateClusterCatalogItemsServer(). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).To(MatchError("logger is mandatory")) + Expect(server).To(BeNil()) + }) + + It("Fails if attribution logic is not set", func() { + server, err := NewPrivateClusterCatalogItemsServer(). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("attribution logic is mandatory")) + Expect(server).To(BeNil()) + }) + + It("Fails if tenancy logic is not set", func() { + server, err := NewPrivateClusterCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + Build() + Expect(err).To(MatchError("tenancy logic is mandatory")) + Expect(server).To(BeNil()) + }) + }) + + Describe("Behaviour", func() { + var server *PrivateClusterCatalogItemsServer + + BeforeEach(func() { + var err error + + // Create the server: + server, err = NewPrivateClusterCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + }) + + It("Creates object", func() { + response, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: "My cluster catalog item", + Description: "My description.", + Template: "my-template-id", + Published: true, + Tenant: "my-tenant", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + object := response.GetObject() + Expect(object).ToNot(BeNil()) + Expect(object.GetId()).ToNot(BeEmpty()) + Expect(object.GetTitle()).To(Equal("My cluster catalog item")) + Expect(object.GetTemplate()).To(Equal("my-template-id")) + Expect(object.GetPublished()).To(BeTrue()) + Expect(object.GetTenant()).To(Equal("my-tenant")) + }) + + It("List objects", func() { + const count = 10 + for i := range count { + _, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: fmt.Sprintf("Catalog item %d", i), + Description: fmt.Sprintf("Description %d.", i), + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + + response, err := server.List(ctx, privatev1.ClusterCatalogItemsListRequest_builder{}.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + items := response.GetItems() + Expect(items).To(HaveLen(count)) + }) + + It("List objects with limit", func() { + const count = 10 + for i := range count { + _, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: fmt.Sprintf("Catalog item %d", i), + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + + response, err := server.List(ctx, privatev1.ClusterCatalogItemsListRequest_builder{ + Limit: proto.Int32(1), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response.GetSize()).To(BeNumerically("==", 1)) + }) + + It("List objects with filter", func() { + const count = 10 + var objects []*privatev1.ClusterCatalogItem + for i := range count { + createResponse, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: fmt.Sprintf("Catalog item %d", i), + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + objects = append(objects, createResponse.GetObject()) + } + DeferCleanup(func() { + for _, object := range objects { + _, err := server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + }) + + for _, object := range objects { + getResponse, err := server.List(ctx, privatev1.ClusterCatalogItemsListRequest_builder{ + Filter: proto.String(fmt.Sprintf("this.id == '%s'", object.GetId())), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetSize()).To(BeNumerically("==", 1)) + Expect(getResponse.GetItems()[0].GetId()).To(Equal(object.GetId())) + } + }) + + It("Get object", func() { + createResponse, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: "My catalog item", + Description: "My description.", + Template: "my-template-id", + Published: true, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + getResponse, err := server.Get(ctx, privatev1.ClusterCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(proto.Equal(createResponse.GetObject(), getResponse.GetObject())).To(BeTrue()) + }) + + It("Update object", func() { + createResponse, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: "Original title", + Description: "Original description.", + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + updateResponse, err := server.Update(ctx, privatev1.ClusterCatalogItemsUpdateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Id: object.GetId(), + Title: "Updated title", + Description: "Updated description.", + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(updateResponse.GetObject().GetTitle()).To(Equal("Updated title")) + Expect(updateResponse.GetObject().GetDescription()).To(Equal("Updated description.")) + + getResponse, err := server.Get(ctx, privatev1.ClusterCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetTitle()).To(Equal("Updated title")) + Expect(getResponse.GetObject().GetDescription()).To(Equal("Updated description.")) + }) + + It("Update published using field mask", func() { + createResponse, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: "My catalog item", + Template: "my-template-id", + Published: false, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + updateResponse, err := server.Update(ctx, privatev1.ClusterCatalogItemsUpdateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Id: object.GetId(), + Published: true, + }.Build(), + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"published"}, + }, + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(updateResponse.GetObject().GetPublished()).To(BeTrue()) + + getResponse, err := server.Get(ctx, privatev1.ClusterCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetPublished()).To(BeTrue()) + }) + + It("Creates object with field definitions and round-trips them", func() { + response, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Title: "Catalog item with fields", + Template: "my-template-id", + FieldDefinitions: []*privatev1.FieldDefinition{ + privatev1.FieldDefinition_builder{ + Path: "spec.network.pod_cidr", + DisplayName: "Pod CIDR", + Editable: true, + ValidationSchema: `{"type":"string","pattern":"^[0-9./]+$"}`, + }.Build(), + privatev1.FieldDefinition_builder{ + Path: "spec.node_sets.workers.size", + DisplayName: "Worker count", + Editable: false, + }.Build(), + }, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := response.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + getResponse, err := server.Get(ctx, privatev1.ClusterCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + fetched := getResponse.GetObject() + Expect(fetched.GetFieldDefinitions()).To(HaveLen(2)) + + fd0 := fetched.GetFieldDefinitions()[0] + Expect(fd0.GetPath()).To(Equal("spec.network.pod_cidr")) + Expect(fd0.GetDisplayName()).To(Equal("Pod CIDR")) + Expect(fd0.GetEditable()).To(BeTrue()) + Expect(fd0.GetValidationSchema()).To(Equal(`{"type":"string","pattern":"^[0-9./]+$"}`)) + + fd1 := fetched.GetFieldDefinitions()[1] + Expect(fd1.GetPath()).To(Equal("spec.node_sets.workers.size")) + Expect(fd1.GetDisplayName()).To(Equal("Worker count")) + Expect(fd1.GetEditable()).To(BeFalse()) + }) + + It("Delete object", func() { + createResponse, err := server.Create(ctx, privatev1.ClusterCatalogItemsCreateRequest_builder{ + Object: privatev1.ClusterCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Finalizers: []string{"a"}, + }.Build(), + Title: "My catalog item", + Template: "my-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + + _, err = server.Delete(ctx, privatev1.ClusterCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + + getResponse, err := server.Get(ctx, privatev1.ClusterCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetMetadata().GetDeletionTimestamp()).ToNot(BeNil()) + }) + }) +}) diff --git a/internal/servers/private_compute_instance_catalog_items_server.go b/internal/servers/private_compute_instance_catalog_items_server.go new file mode 100644 index 000000000..f59c7cae3 --- /dev/null +++ b/internal/servers/private_compute_instance_catalog_items_server.go @@ -0,0 +1,137 @@ +/* +Copyright (c) 2025 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package servers + +import ( + "context" + "errors" + "log/slog" + + "github.com/prometheus/client_golang/prometheus" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/auth" + "github.com/osac-project/fulfillment-service/internal/database" +) + +type PrivateComputeInstanceCatalogItemsServerBuilder struct { + logger *slog.Logger + notifier *database.Notifier + attributionLogic auth.AttributionLogic + tenancyLogic auth.TenancyLogic + metricsRegisterer prometheus.Registerer +} + +var _ privatev1.ComputeInstanceCatalogItemsServer = (*PrivateComputeInstanceCatalogItemsServer)(nil) + +type PrivateComputeInstanceCatalogItemsServer struct { + privatev1.UnimplementedComputeInstanceCatalogItemsServer + logger *slog.Logger + generic *GenericServer[*privatev1.ComputeInstanceCatalogItem] +} + +func NewPrivateComputeInstanceCatalogItemsServer() *PrivateComputeInstanceCatalogItemsServerBuilder { + return &PrivateComputeInstanceCatalogItemsServerBuilder{} +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) SetLogger(value *slog.Logger) *PrivateComputeInstanceCatalogItemsServerBuilder { + b.logger = value + return b +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) SetNotifier( + value *database.Notifier) *PrivateComputeInstanceCatalogItemsServerBuilder { + b.notifier = value + return b +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) SetAttributionLogic(value auth.AttributionLogic) *PrivateComputeInstanceCatalogItemsServerBuilder { + b.attributionLogic = value + return b +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) SetTenancyLogic(value auth.TenancyLogic) *PrivateComputeInstanceCatalogItemsServerBuilder { + b.tenancyLogic = value + return b +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) SetMetricsRegisterer(value prometheus.Registerer) *PrivateComputeInstanceCatalogItemsServerBuilder { + b.metricsRegisterer = value + return b +} + +func (b *PrivateComputeInstanceCatalogItemsServerBuilder) Build() (result *PrivateComputeInstanceCatalogItemsServer, err error) { + if b.logger == nil { + err = errors.New("logger is mandatory") + return + } + if b.tenancyLogic == nil { + err = errors.New("tenancy logic is mandatory") + return + } + + generic, err := NewGenericServer[*privatev1.ComputeInstanceCatalogItem](). + SetLogger(b.logger). + SetService(privatev1.ComputeInstanceCatalogItems_ServiceDesc.ServiceName). + SetNotifier(b.notifier). + SetAttributionLogic(b.attributionLogic). + SetTenancyLogic(b.tenancyLogic). + SetMetricsRegisterer(b.metricsRegisterer). + Build() + if err != nil { + return + } + + result = &PrivateComputeInstanceCatalogItemsServer{ + logger: b.logger, + generic: generic, + } + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) List(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsListRequest) (response *privatev1.ComputeInstanceCatalogItemsListResponse, err error) { + err = s.generic.List(ctx, request, &response) + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) Get(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsGetRequest) (response *privatev1.ComputeInstanceCatalogItemsGetResponse, err error) { + err = s.generic.Get(ctx, request, &response) + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) Create(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsCreateRequest) (response *privatev1.ComputeInstanceCatalogItemsCreateResponse, err error) { + err = s.generic.Create(ctx, request, &response) + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) Update(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsUpdateRequest) (response *privatev1.ComputeInstanceCatalogItemsUpdateResponse, err error) { + err = s.generic.Update(ctx, request, &response) + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) Delete(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsDeleteRequest) (response *privatev1.ComputeInstanceCatalogItemsDeleteResponse, err error) { + err = s.generic.Delete(ctx, request, &response) + return +} + +func (s *PrivateComputeInstanceCatalogItemsServer) Signal(ctx context.Context, + request *privatev1.ComputeInstanceCatalogItemsSignalRequest) (response *privatev1.ComputeInstanceCatalogItemsSignalResponse, err error) { + err = s.generic.Signal(ctx, request, &response) + return +} diff --git a/internal/servers/private_compute_instance_catalog_items_server_test.go b/internal/servers/private_compute_instance_catalog_items_server_test.go new file mode 100644 index 000000000..15fe9fd29 --- /dev/null +++ b/internal/servers/private_compute_instance_catalog_items_server_test.go @@ -0,0 +1,388 @@ +/* +Copyright (c) 2025 Red Hat Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the +License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific +language governing permissions and limitations under the License. +*/ + +package servers + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/fieldmaskpb" + + privatev1 "github.com/osac-project/fulfillment-service/internal/api/osac/private/v1" + "github.com/osac-project/fulfillment-service/internal/database" + "github.com/osac-project/fulfillment-service/internal/database/dao" +) + +var _ = Describe("Private compute instance catalog items server", func() { + var ( + ctx context.Context + tx database.Tx + ) + + BeforeEach(func() { + var err error + + // Create a context: + ctx = context.Background() + + // Prepare the database pool: + db := server.MakeDatabase() + DeferCleanup(db.Close) + pool, err := pgxpool.New(ctx, db.MakeURL()) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(pool.Close) + + // Create the transaction manager: + tm, err := database.NewTxManager(). + SetLogger(logger). + SetPool(pool). + Build() + Expect(err).ToNot(HaveOccurred()) + + // Start a transaction and add it to the context: + tx, err = tm.Begin(ctx) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + err := tm.End(ctx, tx) + Expect(err).ToNot(HaveOccurred()) + }) + ctx = database.TxIntoContext(ctx, tx) + + // Create the tables: + err = dao.CreateTables[*privatev1.ComputeInstanceCatalogItem](ctx) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Creation", func() { + It("Can be built if all the required parameters are set", func() { + server, err := NewPrivateComputeInstanceCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + Expect(server).ToNot(BeNil()) + }) + + It("Fails if logger is not set", func() { + server, err := NewPrivateComputeInstanceCatalogItemsServer(). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).To(MatchError("logger is mandatory")) + Expect(server).To(BeNil()) + }) + + It("Fails if attribution logic is not set", func() { + server, err := NewPrivateComputeInstanceCatalogItemsServer(). + SetLogger(logger). + SetTenancyLogic(tenancy). + Build() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("attribution logic is mandatory")) + Expect(server).To(BeNil()) + }) + + It("Fails if tenancy logic is not set", func() { + server, err := NewPrivateComputeInstanceCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + Build() + Expect(err).To(MatchError("tenancy logic is mandatory")) + Expect(server).To(BeNil()) + }) + }) + + Describe("Behaviour", func() { + var server *PrivateComputeInstanceCatalogItemsServer + + BeforeEach(func() { + var err error + + // Create the server: + server, err = NewPrivateComputeInstanceCatalogItemsServer(). + SetLogger(logger). + SetAttributionLogic(attribution). + SetTenancyLogic(tenancy). + Build() + Expect(err).ToNot(HaveOccurred()) + }) + + It("Creates object", func() { + response, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: "My CI catalog item", + Description: "My description.", + Template: "my-ci-template-id", + Published: true, + Tenant: "my-tenant", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + object := response.GetObject() + Expect(object).ToNot(BeNil()) + Expect(object.GetId()).ToNot(BeEmpty()) + Expect(object.GetTitle()).To(Equal("My CI catalog item")) + Expect(object.GetTemplate()).To(Equal("my-ci-template-id")) + Expect(object.GetPublished()).To(BeTrue()) + Expect(object.GetTenant()).To(Equal("my-tenant")) + }) + + It("List objects", func() { + const count = 10 + for i := range count { + _, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: fmt.Sprintf("CI catalog item %d", i), + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + + response, err := server.List(ctx, privatev1.ComputeInstanceCatalogItemsListRequest_builder{}.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response).ToNot(BeNil()) + items := response.GetItems() + Expect(items).To(HaveLen(count)) + }) + + It("List objects with limit", func() { + const count = 10 + for i := range count { + _, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: fmt.Sprintf("CI catalog item %d", i), + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + + response, err := server.List(ctx, privatev1.ComputeInstanceCatalogItemsListRequest_builder{ + Limit: proto.Int32(1), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(response.GetSize()).To(BeNumerically("==", 1)) + }) + + It("List objects with filter", func() { + const count = 10 + var objects []*privatev1.ComputeInstanceCatalogItem + for i := range count { + createResponse, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: fmt.Sprintf("CI catalog item %d", i), + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + objects = append(objects, createResponse.GetObject()) + } + DeferCleanup(func() { + for _, object := range objects { + _, err := server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + } + }) + + for _, object := range objects { + getResponse, err := server.List(ctx, privatev1.ComputeInstanceCatalogItemsListRequest_builder{ + Filter: proto.String(fmt.Sprintf("this.id == '%s'", object.GetId())), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetSize()).To(BeNumerically("==", 1)) + Expect(getResponse.GetItems()[0].GetId()).To(Equal(object.GetId())) + } + }) + + It("Get object", func() { + createResponse, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: "My CI catalog item", + Description: "My description.", + Template: "my-ci-template-id", + Published: true, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + getResponse, err := server.Get(ctx, privatev1.ComputeInstanceCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(proto.Equal(createResponse.GetObject(), getResponse.GetObject())).To(BeTrue()) + }) + + It("Update object", func() { + createResponse, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: "Original title", + Description: "Original description.", + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + updateResponse, err := server.Update(ctx, privatev1.ComputeInstanceCatalogItemsUpdateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Id: object.GetId(), + Title: "Updated title", + Description: "Updated description.", + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(updateResponse.GetObject().GetTitle()).To(Equal("Updated title")) + Expect(updateResponse.GetObject().GetDescription()).To(Equal("Updated description.")) + + getResponse, err := server.Get(ctx, privatev1.ComputeInstanceCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetTitle()).To(Equal("Updated title")) + Expect(getResponse.GetObject().GetDescription()).To(Equal("Updated description.")) + }) + + It("Update published using field mask", func() { + createResponse, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: "My CI catalog item", + Template: "my-ci-template-id", + Published: false, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + updateResponse, err := server.Update(ctx, privatev1.ComputeInstanceCatalogItemsUpdateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Id: object.GetId(), + Published: true, + }.Build(), + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"published"}, + }, + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(updateResponse.GetObject().GetPublished()).To(BeTrue()) + + getResponse, err := server.Get(ctx, privatev1.ComputeInstanceCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetPublished()).To(BeTrue()) + }) + + It("Creates object with field definitions and round-trips them", func() { + response, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Title: "CI catalog item with fields", + Template: "my-ci-template-id", + FieldDefinitions: []*privatev1.FieldDefinition{ + privatev1.FieldDefinition_builder{ + Path: "spec.cores", + DisplayName: "CPU cores", + Editable: true, + ValidationSchema: `{"type":"number","minimum":1,"maximum":128}`, + }.Build(), + privatev1.FieldDefinition_builder{ + Path: "spec.memory_gib", + DisplayName: "Memory (GiB)", + Editable: false, + }.Build(), + }, + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := response.GetObject() + DeferCleanup(func() { + _, err := server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + }) + + getResponse, err := server.Get(ctx, privatev1.ComputeInstanceCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + fetched := getResponse.GetObject() + Expect(fetched.GetFieldDefinitions()).To(HaveLen(2)) + + fd0 := fetched.GetFieldDefinitions()[0] + Expect(fd0.GetPath()).To(Equal("spec.cores")) + Expect(fd0.GetDisplayName()).To(Equal("CPU cores")) + Expect(fd0.GetEditable()).To(BeTrue()) + Expect(fd0.GetValidationSchema()).To(Equal(`{"type":"number","minimum":1,"maximum":128}`)) + + fd1 := fetched.GetFieldDefinitions()[1] + Expect(fd1.GetPath()).To(Equal("spec.memory_gib")) + Expect(fd1.GetDisplayName()).To(Equal("Memory (GiB)")) + Expect(fd1.GetEditable()).To(BeFalse()) + }) + + It("Delete object", func() { + createResponse, err := server.Create(ctx, privatev1.ComputeInstanceCatalogItemsCreateRequest_builder{ + Object: privatev1.ComputeInstanceCatalogItem_builder{ + Metadata: privatev1.Metadata_builder{ + Finalizers: []string{"a"}, + }.Build(), + Title: "My CI catalog item", + Template: "my-ci-template-id", + }.Build(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + object := createResponse.GetObject() + + _, err = server.Delete(ctx, privatev1.ComputeInstanceCatalogItemsDeleteRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + + getResponse, err := server.Get(ctx, privatev1.ComputeInstanceCatalogItemsGetRequest_builder{ + Id: object.GetId(), + }.Build()) + Expect(err).ToNot(HaveOccurred()) + Expect(getResponse.GetObject().GetMetadata().GetDeletionTimestamp()).ToNot(BeNil()) + }) + }) +})