diff --git a/internal/api/placement/aggregate.go b/internal/api/placement/aggregate.go new file mode 100644 index 0000000..1b89e32 --- /dev/null +++ b/internal/api/placement/aggregate.go @@ -0,0 +1,42 @@ +package placement + +import ( + "encoding/json" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getAggregates(w http.ResponseWriter, r *http.Request) { + aggregates, err := h.service.GetAggregates( + chi.URLParam(r, "resource_provider_uuid"), + ) + if handlePlacementError(w, err, "aggregate lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toAggregatesDocument(aggregates)) +} + +func (h Handler) updateAggregates(w http.ResponseWriter, r *http.Request) { + var request aggregatesDocument + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + aggregates, err := h.service.UpdateAggregates( + chi.URLParam(r, "resource_provider_uuid"), + appplacement.UpdateAggregates{ + ResourceProviderGeneration: request.ResourceProviderGeneration, + Aggregates: request.Aggregates, + }, + ) + if handlePlacementError(w, err, "aggregate update failed") { + return + } + + respond.JSON(w, http.StatusOK, toAggregatesDocument(aggregates)) +} diff --git a/internal/api/placement/aggregate_dto.go b/internal/api/placement/aggregate_dto.go new file mode 100644 index 0000000..f5aa221 --- /dev/null +++ b/internal/api/placement/aggregate_dto.go @@ -0,0 +1,17 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type aggregatesDocument struct { + ResourceProviderGeneration *int `json:"resource_provider_generation,omitempty"` + Aggregates []string `json:"aggregates"` +} + +func toAggregatesDocument( + aggregates appplacement.Aggregates, +) aggregatesDocument { + return aggregatesDocument{ + ResourceProviderGeneration: aggregates.ResourceProviderGeneration, + Aggregates: aggregates.Aggregates, + } +} diff --git a/internal/api/placement/aggregate_test.go b/internal/api/placement/aggregate_test.go new file mode 100644 index 0000000..4c094fb --- /dev/null +++ b/internal/api/placement/aggregate_test.go @@ -0,0 +1,80 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type AggregateSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAggregateSuite(t *testing.T) { + suite.Run(t, new(AggregateSuite)) +} + +func (s *AggregateSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AggregateSuite) TearDownTest() { + s.server.Close() +} + +func (s *AggregateSuite) TestUpdateAndGetAggregates() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + updated, err := resourceproviders.UpdateAggregates( + context.Background(), + client, + provider.UUID, + resourceproviders.UpdateAggregatesOpts{ + ResourceProviderGeneration: &provider.Generation, + Aggregates: []string{ + "aggregate-1", + "aggregate-2", + }, + }, + ).Extract() + s.Require().NoError(err) + + found, err := resourceproviders.GetAggregates( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().ElementsMatch( + []string{"aggregate-1", "aggregate-2"}, + updated.Aggregates, + ) + s.Assert().ElementsMatch(updated.Aggregates, found.Aggregates) +} + +func (s *AggregateSuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} diff --git a/internal/api/placement/allocation.go b/internal/api/placement/allocation.go new file mode 100644 index 0000000..f598002 --- /dev/null +++ b/internal/api/placement/allocation.go @@ -0,0 +1,19 @@ +package placement + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getAllocations(w http.ResponseWriter, r *http.Request) { + allocations, err := h.service.GetAllocations( + chi.URLParam(r, "resource_provider_uuid"), + ) + if handlePlacementError(w, err, "allocation lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toAllocationsDocument(allocations)) +} diff --git a/internal/api/placement/allocation_candidate.go b/internal/api/placement/allocation_candidate.go new file mode 100644 index 0000000..db11a26 --- /dev/null +++ b/internal/api/placement/allocation_candidate.go @@ -0,0 +1,43 @@ +package placement + +import ( + "net/http" + "strconv" + "strings" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +func (h Handler) listAllocationCandidates( + w http.ResponseWriter, + r *http.Request, +) { + candidates := h.service.ListAllocationCandidates( + appplacement.AllocationCandidateQuery{ + Resources: parseRequestedResources( + r.URL.Query().Get("resources"), + ), + }, + ) + + respond.JSON(w, http.StatusOK, toAllocationCandidatesDocument(candidates)) +} + +func parseRequestedResources(value string) map[string]int { + resources := map[string]int{} + for _, part := range strings.Split(value, ",") { + resourceClass, amount, ok := strings.Cut(part, ":") + if !ok { + continue + } + + parsedAmount, err := strconv.Atoi(amount) + if err != nil { + continue + } + resources[resourceClass] = parsedAmount + } + + return resources +} diff --git a/internal/api/placement/allocation_candidate_dto.go b/internal/api/placement/allocation_candidate_dto.go new file mode 100644 index 0000000..59fa60c --- /dev/null +++ b/internal/api/placement/allocation_candidate_dto.go @@ -0,0 +1,88 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type allocationCandidatesDocument struct { + AllocationRequests []allocationRequestDocument `json:"allocation_requests"` + ProviderSummaries map[string]providerSummaryDocument `json:"provider_summaries"` +} + +type allocationRequestDocument struct { + Allocations map[string]allocationDocument `json:"allocations"` +} + +type providerSummaryDocument struct { + Resources map[string]providerSummaryResourceDocument `json:"resources"` + Traits []string `json:"traits,omitempty"` + ParentProviderUUID string `json:"parent_provider_uuid,omitempty"` + RootProviderUUID string `json:"root_provider_uuid,omitempty"` +} + +type providerSummaryResourceDocument struct { + Capacity int `json:"capacity"` + Used int `json:"used"` +} + +func toAllocationCandidatesDocument( + candidates appplacement.AllocationCandidates, +) allocationCandidatesDocument { + return allocationCandidatesDocument{ + AllocationRequests: toAllocationRequestDocuments( + candidates.AllocationRequests, + ), + ProviderSummaries: toProviderSummaryDocuments( + candidates.ProviderSummaries, + ), + } +} + +func toAllocationRequestDocuments( + requests []appplacement.AllocationRequest, +) []allocationRequestDocument { + documents := make([]allocationRequestDocument, 0, len(requests)) + for _, request := range requests { + allocations := map[string]allocationDocument{} + for providerUUID, allocation := range request.Allocations { + allocations[providerUUID] = allocationDocument{ + Resources: allocation.Resources, + } + } + documents = append(documents, allocationRequestDocument{ + Allocations: allocations, + }) + } + + return documents +} + +func toProviderSummaryDocuments( + summaries map[string]appplacement.ProviderSummary, +) map[string]providerSummaryDocument { + documents := map[string]providerSummaryDocument{} + for providerUUID, summary := range summaries { + documents[providerUUID] = providerSummaryDocument{ + Resources: toProviderSummaryResourceDocuments( + summary.Resources, + ), + Traits: summary.Traits, + ParentProviderUUID: summary.ParentProviderUUID, + RootProviderUUID: summary.RootProviderUUID, + } + } + + return documents +} + +func toProviderSummaryResourceDocuments( + resources map[string]appplacement.ProviderSummaryResource, +) map[string]providerSummaryResourceDocument { + documents := map[string]providerSummaryResourceDocument{} + for resourceClass, resource := range resources { + documents[resourceClass] = providerSummaryResourceDocument{ + Capacity: resource.Capacity, + Used: resource.Used, + } + } + + return documents +} diff --git a/internal/api/placement/allocation_candidate_test.go b/internal/api/placement/allocation_candidate_test.go new file mode 100644 index 0000000..e75326e --- /dev/null +++ b/internal/api/placement/allocation_candidate_test.go @@ -0,0 +1,98 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/allocationcandidates" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type AllocationCandidateSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAllocationCandidateSuite(t *testing.T) { + suite.Run(t, new(AllocationCandidateSuite)) +} + +func (s *AllocationCandidateSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AllocationCandidateSuite) TearDownTest() { + s.server.Close() +} + +func (s *AllocationCandidateSuite) TestListAllocationCandidates() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + s.updateInventory(client, provider.UUID) + + pages, err := allocationcandidates.List( + client, + allocationcandidates.ListOpts{ + Resources: "VCPU:2", + }, + ).AllPages(context.Background()) + s.Require().NoError(err) + + candidates, err := allocationcandidates.ExtractAllocationCandidates(pages) + s.Require().NoError(err) + + s.Require().Len(candidates.AllocationRequests, 1) + allocation := candidates.AllocationRequests[0].Allocations[provider.UUID] + s.Assert().Equal(2, allocation.Resources["VCPU"]) + s.Require().Contains(candidates.ProviderSummaries, provider.UUID) + s.Assert().Equal( + 64, + candidates.ProviderSummaries[provider.UUID].Resources["VCPU"].Capacity, + ) +} + +func (s *AllocationCandidateSuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} + +func (s *AllocationCandidateSuite) updateInventory( + client *gophercloud.ServiceClient, + providerUUID string, +) { + _, err := resourceproviders.UpdateInventory( + context.Background(), + client, + providerUUID, + "VCPU", + resourceproviders.UpdateInventoryOpts{ + Inventory: resourceproviders.Inventory{ + AllocationRatio: 16.0, + MaxUnit: 4, + MinUnit: 1, + Reserved: 0, + StepSize: 1, + Total: 4, + }, + }, + ).Extract() + s.Require().NoError(err) +} diff --git a/internal/api/placement/allocation_dto.go b/internal/api/placement/allocation_dto.go new file mode 100644 index 0000000..1e357dc --- /dev/null +++ b/internal/api/placement/allocation_dto.go @@ -0,0 +1,28 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type allocationsDocument struct { + ResourceProviderGeneration int `json:"resource_provider_generation"` + Allocations map[string]allocationDocument `json:"allocations"` +} + +type allocationDocument struct { + Resources map[string]int `json:"resources"` +} + +func toAllocationsDocument( + allocations appplacement.Allocations, +) allocationsDocument { + documents := map[string]allocationDocument{} + for consumerID, allocation := range allocations.Allocations { + documents[consumerID] = allocationDocument{ + Resources: allocation.Resources, + } + } + + return allocationsDocument{ + ResourceProviderGeneration: allocations.ResourceProviderGeneration, + Allocations: documents, + } +} diff --git a/internal/api/placement/allocation_test.go b/internal/api/placement/allocation_test.go new file mode 100644 index 0000000..b6ff070 --- /dev/null +++ b/internal/api/placement/allocation_test.go @@ -0,0 +1,66 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type AllocationSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAllocationSuite(t *testing.T) { + suite.Run(t, new(AllocationSuite)) +} + +func (s *AllocationSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AllocationSuite) TearDownTest() { + s.server.Close() +} + +func (s *AllocationSuite) TestGetAllocations() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + allocations, err := resourceproviders.GetAllocations( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal( + provider.Generation, + allocations.ResourceProviderGeneration, + ) + s.Assert().Empty(allocations.Allocations) +} + +func (s *AllocationSuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} diff --git a/internal/api/placement/inventory.go b/internal/api/placement/inventory.go new file mode 100644 index 0000000..41336c8 --- /dev/null +++ b/internal/api/placement/inventory.go @@ -0,0 +1,122 @@ +package placement + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getInventories(w http.ResponseWriter, r *http.Request) { + inventories, err := h.service.GetInventories( + chi.URLParam(r, "resource_provider_uuid"), + ) + if handlePlacementError(w, err, "inventory lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toInventoriesDocument(inventories)) +} + +func (h Handler) updateInventories(w http.ResponseWriter, r *http.Request) { + var request inventoriesDocument + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + inventories, err := h.service.UpdateInventories( + chi.URLParam(r, "resource_provider_uuid"), + appplacement.UpdateInventories{ + ResourceProviderGeneration: request.ResourceProviderGeneration, + Inventories: toAppInventories(request), + }, + ) + if handlePlacementError(w, err, "inventory update failed") { + return + } + + respond.JSON(w, http.StatusOK, toInventoriesDocument(inventories)) +} + +func (h Handler) deleteInventories(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteInventories(chi.URLParam(r, "resource_provider_uuid")) + if handlePlacementError(w, err, "inventory delete failed") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h Handler) getInventory(w http.ResponseWriter, r *http.Request) { + inventory, err := h.service.GetInventory( + chi.URLParam(r, "resource_provider_uuid"), + chi.URLParam(r, "resource_class"), + ) + if handlePlacementError(w, err, "inventory lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toInventoryWithGenerationDocument(inventory)) +} + +func (h Handler) updateInventory(w http.ResponseWriter, r *http.Request) { + var request inventoryDocument + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + inventory, err := h.service.UpdateInventory( + chi.URLParam(r, "resource_provider_uuid"), + chi.URLParam(r, "resource_class"), + appplacement.UpdateInventory{ + ResourceProviderGeneration: request.ResourceProviderGeneration, + Inventory: toAppInventory( + chi.URLParam(r, "resource_class"), + request, + ), + }, + ) + if handlePlacementError(w, err, "inventory update failed") { + return + } + + respond.JSON(w, http.StatusOK, toInventoryWithGenerationDocument(inventory)) +} + +func (h Handler) deleteInventory(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteInventory( + chi.URLParam(r, "resource_provider_uuid"), + chi.URLParam(r, "resource_class"), + ) + if handlePlacementError(w, err, "inventory delete failed") { + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func handlePlacementError( + w http.ResponseWriter, + err error, + internalMessage string, +) bool { + if err == nil { + return false + } + if errors.Is(err, appplacement.ErrResourceProviderNotFound) { + respond.Error(w, http.StatusNotFound, "resource provider not found") + return true + } + if errors.Is(err, appplacement.ErrInventoryNotFound) { + respond.Error(w, http.StatusNotFound, "inventory not found") + return true + } + + respond.Error(w, http.StatusInternalServerError, internalMessage) + return true +} diff --git a/internal/api/placement/inventory_dto.go b/internal/api/placement/inventory_dto.go new file mode 100644 index 0000000..9e29bae --- /dev/null +++ b/internal/api/placement/inventory_dto.go @@ -0,0 +1,80 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type inventoriesDocument struct { + ResourceProviderGeneration int `json:"resource_provider_generation"` + Inventories map[string]inventoryDocument `json:"inventories"` +} + +type inventoryDocument struct { + ResourceProviderGeneration int `json:"resource_provider_generation,omitempty"` + AllocationRatio float32 `json:"allocation_ratio"` + MaxUnit int `json:"max_unit"` + MinUnit int `json:"min_unit"` + Reserved int `json:"reserved"` + StepSize int `json:"step_size"` + Total int `json:"total"` +} + +func toInventoriesDocument( + inventories appplacement.Inventories, +) inventoriesDocument { + documents := map[string]inventoryDocument{} + for resourceClass, inventory := range inventories.Inventories { + documents[resourceClass] = toInventoryDocument(inventory) + } + + return inventoriesDocument{ + ResourceProviderGeneration: inventories.ResourceProviderGeneration, + Inventories: documents, + } +} + +func toInventoryDocument( + inventory appplacement.Inventory, +) inventoryDocument { + return inventoryDocument{ + AllocationRatio: inventory.AllocationRatio, + MaxUnit: inventory.MaxUnit, + MinUnit: inventory.MinUnit, + Reserved: inventory.Reserved, + StepSize: inventory.StepSize, + Total: inventory.Total, + } +} + +func toInventoryWithGenerationDocument( + inventory appplacement.UpdateInventory, +) inventoryDocument { + document := toInventoryDocument(inventory.Inventory) + document.ResourceProviderGeneration = inventory.ResourceProviderGeneration + + return document +} + +func toAppInventories( + document inventoriesDocument, +) map[string]appplacement.Inventory { + inventories := map[string]appplacement.Inventory{} + for resourceClass, inventory := range document.Inventories { + inventories[resourceClass] = toAppInventory(resourceClass, inventory) + } + + return inventories +} + +func toAppInventory( + resourceClass string, + document inventoryDocument, +) appplacement.Inventory { + return appplacement.Inventory{ + ResourceClass: resourceClass, + AllocationRatio: document.AllocationRatio, + MaxUnit: document.MaxUnit, + MinUnit: document.MinUnit, + Reserved: document.Reserved, + StepSize: document.StepSize, + Total: document.Total, + } +} diff --git a/internal/api/placement/inventory_test.go b/internal/api/placement/inventory_test.go new file mode 100644 index 0000000..98fdaf9 --- /dev/null +++ b/internal/api/placement/inventory_test.go @@ -0,0 +1,201 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type InventorySuite struct { + suite.Suite + server *httptest.Server +} + +func TestInventorySuite(t *testing.T) { + suite.Run(t, new(InventorySuite)) +} + +func (s *InventorySuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *InventorySuite) TearDownTest() { + s.server.Close() +} + +func (s *InventorySuite) TestUpdateAndGetInventories() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + updated, err := resourceproviders.UpdateInventories( + context.Background(), + client, + provider.UUID, + resourceproviders.UpdateInventoriesOpts{ + ResourceProviderGeneration: provider.Generation, + Inventories: map[string]resourceproviders.Inventory{ + "VCPU": { + AllocationRatio: 16.0, + MaxUnit: 4, + MinUnit: 1, + Reserved: 0, + StepSize: 1, + Total: 4, + }, + }, + }, + ).Extract() + s.Require().NoError(err) + + found, err := resourceproviders.GetInventories( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Require().Contains(updated.Inventories, "VCPU") + s.Assert().Equal(4, updated.Inventories["VCPU"].Total) + s.Require().Contains(found.Inventories, "VCPU") + s.Assert().Equal(float32(16.0), found.Inventories["VCPU"].AllocationRatio) +} + +func (s *InventorySuite) TestUpdateAndGetInventory() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + updated, err := resourceproviders.UpdateInventory( + context.Background(), + client, + provider.UUID, + "MEMORY_MB", + resourceproviders.UpdateInventoryOpts{ + ResourceProviderGeneration: provider.Generation, + Inventory: resourceproviders.Inventory{ + AllocationRatio: 1.5, + MaxUnit: 2048, + MinUnit: 1, + Reserved: 128, + StepSize: 1, + Total: 2048, + }, + }, + ).Extract() + s.Require().NoError(err) + + found, err := resourceproviders.GetInventory( + context.Background(), + client, + provider.UUID, + "MEMORY_MB", + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal(2048, updated.Total) + s.Assert().Equal(128, found.Reserved) + s.Assert().Equal(float32(1.5), found.AllocationRatio) +} + +func (s *InventorySuite) TestDeleteInventory() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + _, err := resourceproviders.UpdateInventory( + context.Background(), + client, + provider.UUID, + "VCPU", + resourceproviders.UpdateInventoryOpts{ + ResourceProviderGeneration: provider.Generation, + Inventory: resourceproviders.Inventory{ + AllocationRatio: 16.0, + MaxUnit: 4, + MinUnit: 1, + Reserved: 0, + StepSize: 1, + Total: 4, + }, + }, + ).Extract() + s.Require().NoError(err) + + err = resourceproviders.DeleteInventory( + context.Background(), + client, + provider.UUID, + "VCPU", + ).ExtractErr() + s.Require().NoError(err) + + _, err = resourceproviders.GetInventory( + context.Background(), + client, + provider.UUID, + "VCPU", + ).Extract() + s.Assert().Error(err) +} + +func (s *InventorySuite) TestDeleteInventories() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + _, err := resourceproviders.UpdateInventory( + context.Background(), + client, + provider.UUID, + "VCPU", + resourceproviders.UpdateInventoryOpts{ + ResourceProviderGeneration: provider.Generation, + Inventory: resourceproviders.Inventory{ + AllocationRatio: 16.0, + MaxUnit: 4, + MinUnit: 1, + Reserved: 0, + StepSize: 1, + Total: 4, + }, + }, + ).Extract() + s.Require().NoError(err) + + err = resourceproviders.DeleteInventories( + context.Background(), + client, + provider.UUID, + ).ExtractErr() + s.Require().NoError(err) + + found, err := resourceproviders.GetInventories( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Empty(found.Inventories) +} + +func (s *InventorySuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} diff --git a/internal/api/placement/resource_class.go b/internal/api/placement/resource_class.go new file mode 100644 index 0000000..2b488e1 --- /dev/null +++ b/internal/api/placement/resource_class.go @@ -0,0 +1,82 @@ +package placement + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listResourceClasses(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, resourceClassListResponse{ + ResourceClasses: toResourceClassDocuments( + h.service.ListResourceClasses(), + ), + }) +} + +func (h Handler) createResourceClass(w http.ResponseWriter, r *http.Request) { + var request createResourceClassRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + h.service.CreateResourceClass(request.Name) + w.WriteHeader(http.StatusCreated) +} + +func (h Handler) getResourceClass(w http.ResponseWriter, r *http.Request) { + class, err := h.service.GetResourceClass( + chi.URLParam(r, "resource_class"), + ) + if errors.Is(err, appplacement.ErrResourceClassNotFound) { + respond.Error(w, http.StatusNotFound, "resource class not found") + return + } + if err != nil { + respond.Error( + w, + http.StatusInternalServerError, + "resource class lookup failed", + ) + return + } + + respond.JSON(w, http.StatusOK, toResourceClassDocument(class)) +} + +func (h Handler) updateResourceClass(w http.ResponseWriter, r *http.Request) { + _, err := h.service.GetResourceClass( + chi.URLParam(r, "resource_class"), + ) + + h.service.EnsureResourceClass(chi.URLParam(r, "resource_class")) + if err == nil { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h Handler) deleteResourceClass(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteResourceClass(chi.URLParam(r, "resource_class")) + if errors.Is(err, appplacement.ErrResourceClassNotFound) { + respond.Error(w, http.StatusNotFound, "resource class not found") + return + } + if err != nil { + respond.Error( + w, + http.StatusInternalServerError, + "resource class delete failed", + ) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/placement/resource_class_dto.go b/internal/api/placement/resource_class_dto.go new file mode 100644 index 0000000..25674d9 --- /dev/null +++ b/internal/api/placement/resource_class_dto.go @@ -0,0 +1,46 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type createResourceClassRequest struct { + Name string `json:"name"` +} + +type resourceClassListResponse struct { + ResourceClasses []resourceClassDocument `json:"resource_classes"` +} + +type resourceClassDocument struct { + Name string `json:"name"` + Links []resourceClassLinkDocument `json:"links"` +} + +type resourceClassLinkDocument struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +func toResourceClassDocuments( + classes []appplacement.ResourceClass, +) []resourceClassDocument { + documents := make([]resourceClassDocument, 0, len(classes)) + for _, class := range classes { + documents = append(documents, toResourceClassDocument(class)) + } + + return documents +} + +func toResourceClassDocument( + class appplacement.ResourceClass, +) resourceClassDocument { + return resourceClassDocument{ + Name: class.Name, + Links: []resourceClassLinkDocument{ + { + Href: "/placement/resource_classes/" + class.Name, + Rel: "self", + }, + }, + } +} diff --git a/internal/api/placement/resource_class_test.go b/internal/api/placement/resource_class_test.go new file mode 100644 index 0000000..25275ec --- /dev/null +++ b/internal/api/placement/resource_class_test.go @@ -0,0 +1,97 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceclasses" + "github.com/stretchr/testify/suite" +) + +type ResourceClassSuite struct { + suite.Suite + server *httptest.Server +} + +func TestResourceClassSuite(t *testing.T) { + suite.Run(t, new(ResourceClassSuite)) +} + +func (s *ResourceClassSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ResourceClassSuite) TearDownTest() { + s.server.Close() +} + +func (s *ResourceClassSuite) TestCreateAndGetResourceClass() { + client := testhelper.ServiceClient(s.server.URL) + + err := resourceclasses.Create( + context.Background(), + client, + resourceclasses.CreateOpts{Name: "CUSTOM_GPU"}, + ).ExtractErr() + s.Require().NoError(err) + + class, err := resourceclasses.Get( + context.Background(), + client, + "CUSTOM_GPU", + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("CUSTOM_GPU", class.Name) +} + +func (s *ResourceClassSuite) TestUpdateResourceClass() { + client := testhelper.ServiceClient(s.server.URL) + + err := resourceclasses.Update( + context.Background(), + client, + "CUSTOM_FPGA", + ).ExtractErr() + s.Require().NoError(err) + + pages, err := resourceclasses.List(client).AllPages(context.Background()) + s.Require().NoError(err) + + classes, err := resourceclasses.ExtractResourceClasses(pages) + s.Require().NoError(err) + + s.Require().Len(classes, 1) + s.Assert().Equal("CUSTOM_FPGA", classes[0].Name) +} + +func (s *ResourceClassSuite) TestDeleteResourceClass() { + client := testhelper.ServiceClient(s.server.URL) + + err := resourceclasses.Create( + context.Background(), + client, + resourceclasses.CreateOpts{Name: "CUSTOM_GPU"}, + ).ExtractErr() + s.Require().NoError(err) + + err = resourceclasses.Delete( + context.Background(), + client, + "CUSTOM_GPU", + ).ExtractErr() + s.Require().NoError(err) + + pages, err := resourceclasses.List(client).AllPages(context.Background()) + s.Require().NoError(err) + + classes, err := resourceclasses.ExtractResourceClasses(pages) + s.Require().NoError(err) + + s.Assert().Empty(classes) +} diff --git a/internal/api/placement/resource_provider.go b/internal/api/placement/resource_provider.go new file mode 100644 index 0000000..bf9fa26 --- /dev/null +++ b/internal/api/placement/resource_provider.go @@ -0,0 +1,77 @@ +package placement + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listResourceProviders(w http.ResponseWriter, r *http.Request) { + providers := h.service.ListResourceProviders() + + respond.JSON(w, http.StatusOK, resourceProviderListResponse{ + ResourceProviders: toResourceProviderDocuments(providers), + }) +} + +func (h Handler) createResourceProvider(w http.ResponseWriter, r *http.Request) { + var request createResourceProviderRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + provider := h.service.CreateResourceProvider( + appplacement.CreateResourceProvider{ + UUID: request.UUID, + Name: request.Name, + ParentProviderUUID: request.ParentProviderUUID, + }, + ) + + respond.JSON(w, http.StatusOK, toResourceProviderDocument(provider)) +} + +func (h Handler) getResourceProvider(w http.ResponseWriter, r *http.Request) { + provider, err := h.service.GetResourceProvider( + chi.URLParam(r, "resource_provider_uuid"), + ) + if errors.Is(err, appplacement.ErrResourceProviderNotFound) { + respond.Error(w, http.StatusNotFound, "resource provider not found") + return + } + if err != nil { + respond.Error( + w, + http.StatusInternalServerError, + "resource provider lookup failed", + ) + return + } + + respond.JSON(w, http.StatusOK, toResourceProviderDocument(provider)) +} + +func (h Handler) deleteResourceProvider(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteResourceProvider( + chi.URLParam(r, "resource_provider_uuid"), + ) + if errors.Is(err, appplacement.ErrResourceProviderNotFound) { + respond.Error(w, http.StatusNotFound, "resource provider not found") + return + } + if err != nil { + respond.Error( + w, + http.StatusInternalServerError, + "resource provider delete failed", + ) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/placement/resource_provider_dto.go b/internal/api/placement/resource_provider_dto.go new file mode 100644 index 0000000..28c049e --- /dev/null +++ b/internal/api/placement/resource_provider_dto.go @@ -0,0 +1,56 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type createResourceProviderRequest struct { + Name string `json:"name"` + UUID string `json:"uuid"` + ParentProviderUUID string `json:"parent_provider_uuid"` +} + +type resourceProviderListResponse struct { + ResourceProviders []resourceProviderDocument `json:"resource_providers"` +} + +type resourceProviderDocument struct { + Generation int `json:"generation"` + UUID string `json:"uuid"` + Links []resourceProviderLinkDocument `json:"links"` + Name string `json:"name"` + ParentProviderUUID string `json:"parent_provider_uuid"` + RootProviderUUID string `json:"root_provider_uuid"` +} + +type resourceProviderLinkDocument struct { + Href string `json:"href"` + Rel string `json:"rel"` +} + +func toResourceProviderDocuments( + providers []appplacement.ResourceProvider, +) []resourceProviderDocument { + documents := make([]resourceProviderDocument, 0, len(providers)) + for _, provider := range providers { + documents = append(documents, toResourceProviderDocument(provider)) + } + + return documents +} + +func toResourceProviderDocument( + provider appplacement.ResourceProvider, +) resourceProviderDocument { + return resourceProviderDocument{ + Generation: provider.Generation, + UUID: provider.UUID, + Links: []resourceProviderLinkDocument{ + { + Href: "/placement/resource_providers/" + provider.UUID, + Rel: "self", + }, + }, + Name: provider.Name, + ParentProviderUUID: provider.ParentProviderUUID, + RootProviderUUID: provider.RootProviderUUID, + } +} diff --git a/internal/api/placement/resource_provider_test.go b/internal/api/placement/resource_provider_test.go new file mode 100644 index 0000000..68abf17 --- /dev/null +++ b/internal/api/placement/resource_provider_test.go @@ -0,0 +1,121 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type ResourceProviderSuite struct { + suite.Suite + server *httptest.Server +} + +func TestResourceProviderSuite(t *testing.T) { + suite.Run(t, new(ResourceProviderSuite)) +} + +func (s *ResourceProviderSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ResourceProviderSuite) TearDownTest() { + s.server.Close() +} + +func (s *ResourceProviderSuite) TestListEmptyResourceProviders() { + client := testhelper.ServiceClient(s.server.URL) + + pages, err := resourceproviders.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + providers, err := resourceproviders.ExtractResourceProviders(pages) + s.Require().NoError(err) + + s.Assert().Empty(providers) +} + +func (s *ResourceProviderSuite) TestCreateAndGetResourceProvider() { + client := testhelper.ServiceClient(s.server.URL) + + created, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + found, err := resourceproviders.Get( + context.Background(), + client, + created.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("resource-provider-1", found.UUID) + s.Assert().Equal("compute-1", found.Name) + s.Assert().Equal("resource-provider-1", found.RootProviderUUID) +} + +func (s *ResourceProviderSuite) TestListResourceProviders() { + client := testhelper.ServiceClient(s.server.URL) + + _, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + pages, err := resourceproviders.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + providers, err := resourceproviders.ExtractResourceProviders(pages) + s.Require().NoError(err) + + s.Require().Len(providers, 1) + s.Assert().Equal("resource-provider-1", providers[0].UUID) + s.Assert().Equal("compute-1", providers[0].Name) +} + +func (s *ResourceProviderSuite) TestDeleteResourceProvider() { + client := testhelper.ServiceClient(s.server.URL) + + created, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + err = resourceproviders.Delete( + context.Background(), + client, + created.UUID, + ).ExtractErr() + s.Require().NoError(err) + + pages, err := resourceproviders.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + providers, err := resourceproviders.ExtractResourceProviders(pages) + s.Require().NoError(err) + + s.Assert().Empty(providers) +} diff --git a/internal/api/placement/router.go b/internal/api/placement/router.go index e4e9458..a7bd8af 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -3,25 +3,133 @@ package placement import ( "net/http" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" "github.com/JSYoo5B/SandStack/internal/platform/config" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + storeplacement "github.com/JSYoo5B/SandStack/internal/store/placement" "github.com/go-chi/chi/v5" ) type Handler struct { - config config.Config + config config.Config + service *appplacement.Service } func NewRouter(cfg config.Config) http.Handler { return NewHandler(cfg).Router() } +func NewRouterWithService( + cfg config.Config, + service *appplacement.Service, +) http.Handler { + return NewHandlerWithService(cfg, service).Router() +} + func NewHandler(cfg config.Config) Handler { - return Handler{config: cfg} + return NewHandlerWithService( + cfg, + appplacement.NewServiceWithRepositories( + appplacement.Repositories{ + ResourceProviders: storeplacement.NewMemoryResourceProviderRepository(), + Inventories: storeplacement.NewMemoryInventoryRepository(), + ProviderTraits: storeplacement.NewMemoryTraitRepository(), + Aggregates: storeplacement.NewMemoryAggregateRepository(), + Usages: storeplacement.NewMemoryUsageRepository(), + Allocations: storeplacement.NewMemoryAllocationRepository(), + ResourceClasses: storeplacement.NewMemoryResourceClassRepository(), + TraitCatalog: storeplacement.NewMemoryTraitCatalogRepository(), + }, + idgen.Random(), + ), + ) +} + +func NewHandlerWithService( + cfg config.Config, + service *appplacement.Service, +) Handler { + return Handler{ + config: cfg, + service: service, + } } func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.versions) + router.Get("/resource_providers", h.listResourceProviders) + router.Post("/resource_providers", h.createResourceProvider) + router.Get( + "/resource_providers/{resource_provider_uuid}", + h.getResourceProvider, + ) + router.Delete( + "/resource_providers/{resource_provider_uuid}", + h.deleteResourceProvider, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/inventories", + h.getInventories, + ) + router.Put( + "/resource_providers/{resource_provider_uuid}/inventories", + h.updateInventories, + ) + router.Delete( + "/resource_providers/{resource_provider_uuid}/inventories", + h.deleteInventories, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/inventories/{resource_class}", + h.getInventory, + ) + router.Put( + "/resource_providers/{resource_provider_uuid}/inventories/{resource_class}", + h.updateInventory, + ) + router.Delete( + "/resource_providers/{resource_provider_uuid}/inventories/{resource_class}", + h.deleteInventory, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/traits", + h.getTraits, + ) + router.Put( + "/resource_providers/{resource_provider_uuid}/traits", + h.updateTraits, + ) + router.Delete( + "/resource_providers/{resource_provider_uuid}/traits", + h.deleteTraits, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/aggregates", + h.getAggregates, + ) + router.Put( + "/resource_providers/{resource_provider_uuid}/aggregates", + h.updateAggregates, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/usages", + h.getUsages, + ) + router.Get( + "/resource_providers/{resource_provider_uuid}/allocations", + h.getAllocations, + ) + router.Get("/allocation_candidates", h.listAllocationCandidates) + router.Get("/resource_classes", h.listResourceClasses) + router.Post("/resource_classes", h.createResourceClass) + router.Get("/resource_classes/{resource_class}", h.getResourceClass) + router.Put("/resource_classes/{resource_class}", h.updateResourceClass) + router.Delete("/resource_classes/{resource_class}", h.deleteResourceClass) + router.Get("/traits", h.listTraits) + router.Get("/traits/{trait_name}", h.getTrait) + router.Put("/traits/{trait_name}", h.createTrait) + router.Delete("/traits/{trait_name}", h.deleteTrait) return router } diff --git a/internal/api/placement/trait.go b/internal/api/placement/trait.go new file mode 100644 index 0000000..d7234d5 --- /dev/null +++ b/internal/api/placement/trait.go @@ -0,0 +1,51 @@ +package placement + +import ( + "encoding/json" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getTraits(w http.ResponseWriter, r *http.Request) { + traits, err := h.service.GetTraits( + chi.URLParam(r, "resource_provider_uuid"), + ) + if handlePlacementError(w, err, "trait lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toTraitsDocument(traits)) +} + +func (h Handler) updateTraits(w http.ResponseWriter, r *http.Request) { + var request traitsDocument + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + traits, err := h.service.UpdateTraits( + chi.URLParam(r, "resource_provider_uuid"), + appplacement.UpdateTraits{ + ResourceProviderGeneration: request.ResourceProviderGeneration, + Traits: request.Traits, + }, + ) + if handlePlacementError(w, err, "trait update failed") { + return + } + + respond.JSON(w, http.StatusOK, toTraitsDocument(traits)) +} + +func (h Handler) deleteTraits(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteTraits(chi.URLParam(r, "resource_provider_uuid")) + if handlePlacementError(w, err, "trait delete failed") { + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/placement/trait_catalog.go b/internal/api/placement/trait_catalog.go new file mode 100644 index 0000000..e6261e6 --- /dev/null +++ b/internal/api/placement/trait_catalog.go @@ -0,0 +1,54 @@ +package placement + +import ( + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listTraits(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, traitListResponse{ + Traits: h.service.ListTraits(r.URL.Query().Get("name")), + }) +} + +func (h Handler) getTrait(w http.ResponseWriter, r *http.Request) { + _, err := h.service.GetTrait(chi.URLParam(r, "trait_name")) + if errors.Is(err, appplacement.ErrTraitNotFound) { + respond.Error(w, http.StatusNotFound, "trait not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "trait lookup failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h Handler) createTrait(w http.ResponseWriter, r *http.Request) { + created := h.service.CreateTrait(chi.URLParam(r, "trait_name")) + if !created { + w.WriteHeader(http.StatusNoContent) + return + } + + w.WriteHeader(http.StatusCreated) +} + +func (h Handler) deleteTrait(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteTrait(chi.URLParam(r, "trait_name")) + if errors.Is(err, appplacement.ErrTraitNotFound) { + respond.Error(w, http.StatusNotFound, "trait not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "trait delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/placement/trait_catalog_dto.go b/internal/api/placement/trait_catalog_dto.go new file mode 100644 index 0000000..4f7003e --- /dev/null +++ b/internal/api/placement/trait_catalog_dto.go @@ -0,0 +1,5 @@ +package placement + +type traitListResponse struct { + Traits []string `json:"traits"` +} diff --git a/internal/api/placement/trait_catalog_test.go b/internal/api/placement/trait_catalog_test.go new file mode 100644 index 0000000..fd212f2 --- /dev/null +++ b/internal/api/placement/trait_catalog_test.go @@ -0,0 +1,103 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/traits" + "github.com/stretchr/testify/suite" +) + +type TraitCatalogSuite struct { + suite.Suite + server *httptest.Server +} + +func TestTraitCatalogSuite(t *testing.T) { + suite.Run(t, new(TraitCatalogSuite)) +} + +func (s *TraitCatalogSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *TraitCatalogSuite) TearDownTest() { + s.server.Close() +} + +func (s *TraitCatalogSuite) TestCreateGetAndListTrait() { + client := testhelper.ServiceClient(s.server.URL) + + err := traits.Create( + context.Background(), + client, + "CUSTOM_GPU", + ).ExtractErr() + s.Require().NoError(err) + + err = traits.Get(context.Background(), client, "CUSTOM_GPU").ExtractErr() + s.Require().NoError(err) + + pages, err := traits.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + found, err := traits.ExtractTraits(pages) + s.Require().NoError(err) + + s.Assert().Equal([]string{"CUSTOM_GPU"}, found) +} + +func (s *TraitCatalogSuite) TestListTraitWithNameFilter() { + client := testhelper.ServiceClient(s.server.URL) + + err := traits.Create( + context.Background(), + client, + "CUSTOM_GPU", + ).ExtractErr() + s.Require().NoError(err) + err = traits.Create( + context.Background(), + client, + "CUSTOM_FPGA", + ).ExtractErr() + s.Require().NoError(err) + + pages, err := traits.List( + client, + traits.ListOpts{Name: "startswith:CUSTOM_G"}, + ).AllPages(context.Background()) + s.Require().NoError(err) + + found, err := traits.ExtractTraits(pages) + s.Require().NoError(err) + + s.Assert().Equal([]string{"CUSTOM_GPU"}, found) +} + +func (s *TraitCatalogSuite) TestDeleteTrait() { + client := testhelper.ServiceClient(s.server.URL) + + err := traits.Create( + context.Background(), + client, + "CUSTOM_GPU", + ).ExtractErr() + s.Require().NoError(err) + + err = traits.Delete(context.Background(), client, "CUSTOM_GPU").ExtractErr() + s.Require().NoError(err) + + pages, err := traits.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + found, err := traits.ExtractTraits(pages) + s.Require().NoError(err) + + s.Assert().Empty(found) +} diff --git a/internal/api/placement/trait_dto.go b/internal/api/placement/trait_dto.go new file mode 100644 index 0000000..74c1002 --- /dev/null +++ b/internal/api/placement/trait_dto.go @@ -0,0 +1,15 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type traitsDocument struct { + ResourceProviderGeneration int `json:"resource_provider_generation"` + Traits []string `json:"traits"` +} + +func toTraitsDocument(traits appplacement.Traits) traitsDocument { + return traitsDocument{ + ResourceProviderGeneration: traits.ResourceProviderGeneration, + Traits: traits.Traits, + } +} diff --git a/internal/api/placement/trait_test.go b/internal/api/placement/trait_test.go new file mode 100644 index 0000000..bbc445d --- /dev/null +++ b/internal/api/placement/trait_test.go @@ -0,0 +1,114 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type TraitSuite struct { + suite.Suite + server *httptest.Server +} + +func TestTraitSuite(t *testing.T) { + suite.Run(t, new(TraitSuite)) +} + +func (s *TraitSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *TraitSuite) TearDownTest() { + s.server.Close() +} + +func (s *TraitSuite) TestUpdateAndGetTraits() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + updated, err := resourceproviders.UpdateTraits( + context.Background(), + client, + provider.UUID, + resourceproviders.UpdateTraitsOpts{ + ResourceProviderGeneration: provider.Generation, + Traits: []string{ + "CUSTOM_FAST_DISK", + "CUSTOM_GPU", + }, + }, + ).Extract() + s.Require().NoError(err) + + found, err := resourceproviders.GetTraits( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().ElementsMatch( + []string{"CUSTOM_FAST_DISK", "CUSTOM_GPU"}, + updated.Traits, + ) + s.Assert().ElementsMatch(updated.Traits, found.Traits) +} + +func (s *TraitSuite) TestDeleteTraits() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + _, err := resourceproviders.UpdateTraits( + context.Background(), + client, + provider.UUID, + resourceproviders.UpdateTraitsOpts{ + ResourceProviderGeneration: provider.Generation, + Traits: []string{ + "CUSTOM_GPU", + }, + }, + ).Extract() + s.Require().NoError(err) + + err = resourceproviders.DeleteTraits( + context.Background(), + client, + provider.UUID, + ).ExtractErr() + s.Require().NoError(err) + + found, err := resourceproviders.GetTraits( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Empty(found.Traits) +} + +func (s *TraitSuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} diff --git a/internal/api/placement/usage.go b/internal/api/placement/usage.go new file mode 100644 index 0000000..30dca73 --- /dev/null +++ b/internal/api/placement/usage.go @@ -0,0 +1,19 @@ +package placement + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getUsages(w http.ResponseWriter, r *http.Request) { + usages, err := h.service.GetUsages( + chi.URLParam(r, "resource_provider_uuid"), + ) + if handlePlacementError(w, err, "usage lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, toUsagesDocument(usages)) +} diff --git a/internal/api/placement/usage_dto.go b/internal/api/placement/usage_dto.go new file mode 100644 index 0000000..a8a0902 --- /dev/null +++ b/internal/api/placement/usage_dto.go @@ -0,0 +1,15 @@ +package placement + +import appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" + +type usagesDocument struct { + ResourceProviderGeneration int `json:"resource_provider_generation"` + Usages map[string]int `json:"usages"` +} + +func toUsagesDocument(usages appplacement.Usages) usagesDocument { + return usagesDocument{ + ResourceProviderGeneration: usages.ResourceProviderGeneration, + Usages: usages.Usages, + } +} diff --git a/internal/api/placement/usage_test.go b/internal/api/placement/usage_test.go new file mode 100644 index 0000000..8eb2ed7 --- /dev/null +++ b/internal/api/placement/usage_test.go @@ -0,0 +1,63 @@ +package placement_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/placement" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/placement/v1/resourceproviders" + "github.com/stretchr/testify/suite" +) + +type UsageSuite struct { + suite.Suite + server *httptest.Server +} + +func TestUsageSuite(t *testing.T) { + suite.Run(t, new(UsageSuite)) +} + +func (s *UsageSuite) SetupTest() { + s.server = httptest.NewServer( + placement.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *UsageSuite) TearDownTest() { + s.server.Close() +} + +func (s *UsageSuite) TestGetUsages() { + client := testhelper.ServiceClient(s.server.URL) + provider := s.createResourceProvider(client) + + usages, err := resourceproviders.GetUsages( + context.Background(), + client, + provider.UUID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal(provider.Generation, usages.ResourceProviderGeneration) + s.Assert().Empty(usages.Usages) +} + +func (s *UsageSuite) createResourceProvider( + client *gophercloud.ServiceClient, +) *resourceproviders.ResourceProvider { + provider, err := resourceproviders.Create( + context.Background(), + client, + resourceproviders.CreateOpts{ + Name: "compute-1", + UUID: "resource-provider-1", + }, + ).Extract() + s.Require().NoError(err) + + return provider +} diff --git a/internal/api/router.go b/internal/api/router.go index 0c62ab8..bb8eb83 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -14,6 +14,7 @@ import ( appidentity "github.com/JSYoo5B/SandStack/internal/app/identity" appimage "github.com/JSYoo5B/SandStack/internal/app/image" appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" "github.com/JSYoo5B/SandStack/internal/app/requestlog" appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" "github.com/JSYoo5B/SandStack/internal/platform/clock" @@ -23,6 +24,7 @@ import ( storeidentity "github.com/JSYoo5B/SandStack/internal/store/identity" storeimage "github.com/JSYoo5B/SandStack/internal/store/image" storenetwork "github.com/JSYoo5B/SandStack/internal/store/network" + storeplacement "github.com/JSYoo5B/SandStack/internal/store/placement" storevolume "github.com/JSYoo5B/SandStack/internal/store/volume" "github.com/go-chi/chi/v5" ) @@ -81,12 +83,26 @@ func NewRouter(cfg config.Config) http.Handler { clock.Wall(), idgen.Random(), ) + placementService := appplacement.NewServiceWithRepositories( + appplacement.Repositories{ + ResourceProviders: storeplacement.NewMemoryResourceProviderRepository(), + Inventories: storeplacement.NewMemoryInventoryRepository(), + ProviderTraits: storeplacement.NewMemoryTraitRepository(), + Aggregates: storeplacement.NewMemoryAggregateRepository(), + Usages: storeplacement.NewMemoryUsageRepository(), + Allocations: storeplacement.NewMemoryAllocationRepository(), + ResourceClasses: storeplacement.NewMemoryResourceClassRepository(), + TraitCatalog: storeplacement.NewMemoryTraitCatalogRepository(), + }, + idgen.Random(), + ) router.Mount("/_sandstack", admin.NewRouterWithState(func() { identityService.Reset() computeService.Reset() imageService.Reset() networkService.Reset() + placementService.Reset() volumeService.Reset() requests.Reset() }, requests)) @@ -98,7 +114,7 @@ func NewRouter(cfg config.Config) http.Handler { router.Mount("/compute/v2.1", compute.NewRouterWithService(cfg, computeService)) router.Mount("/image/v2", image.NewRouterWithService(cfg, imageService)) router.Mount("/network/v2.0", network.NewRouterWithService(cfg, networkService)) - router.Mount("/placement", placement.NewRouter(cfg)) + router.Mount("/placement", placement.NewRouterWithService(cfg, placementService)) router.Mount("/volume/v3", volume.NewRouterWithService(cfg, volumeService)) return router diff --git a/internal/app/placement/aggregate.go b/internal/app/placement/aggregate.go new file mode 100644 index 0000000..70cb29b --- /dev/null +++ b/internal/app/placement/aggregate.go @@ -0,0 +1,40 @@ +package placement + +func (s *Service) GetAggregates( + resourceProviderUUID string, +) (Aggregates, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Aggregates{}, err + } + + return Aggregates{ + ResourceProviderGeneration: intPtr(provider.Generation), + Aggregates: s.aggregateRepository.Get( + resourceProviderUUID, + ), + }, nil +} + +func (s *Service) UpdateAggregates( + resourceProviderUUID string, + input UpdateAggregates, +) (Aggregates, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Aggregates{}, err + } + + s.aggregateRepository.Set(resourceProviderUUID, input.Aggregates) + + return Aggregates{ + ResourceProviderGeneration: intPtr(provider.Generation), + Aggregates: s.aggregateRepository.Get( + resourceProviderUUID, + ), + }, nil +} + +func intPtr(value int) *int { + return &value +} diff --git a/internal/app/placement/allocation.go b/internal/app/placement/allocation.go new file mode 100644 index 0000000..b0431c9 --- /dev/null +++ b/internal/app/placement/allocation.go @@ -0,0 +1,17 @@ +package placement + +func (s *Service) GetAllocations( + resourceProviderUUID string, +) (Allocations, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Allocations{}, err + } + + return Allocations{ + ResourceProviderGeneration: provider.Generation, + Allocations: s.allocationRepository.Get( + resourceProviderUUID, + ), + }, nil +} diff --git a/internal/app/placement/allocation_candidate.go b/internal/app/placement/allocation_candidate.go new file mode 100644 index 0000000..9ab3cae --- /dev/null +++ b/internal/app/placement/allocation_candidate.go @@ -0,0 +1,90 @@ +package placement + +func (s *Service) ListAllocationCandidates( + query AllocationCandidateQuery, +) AllocationCandidates { + candidates := AllocationCandidates{ + AllocationRequests: []AllocationRequest{}, + ProviderSummaries: map[string]ProviderSummary{}, + } + + if len(query.Resources) == 0 { + return candidates + } + + for _, provider := range s.resourceProviderRepository.List() { + inventories := s.inventoryRepository.GetAll(provider.UUID) + usages := s.usageRepository.Get(provider.UUID) + + if !canSatisfyResources(inventories, usages, query.Resources) { + continue + } + + candidates.AllocationRequests = append( + candidates.AllocationRequests, + AllocationRequest{ + Allocations: map[string]Allocation{ + provider.UUID: { + Resources: copyIntMap(query.Resources), + }, + }, + }, + ) + candidates.ProviderSummaries[provider.UUID] = ProviderSummary{ + Resources: toProviderSummaryResources(inventories, usages), + Traits: s.traitRepository.Get(provider.UUID), + ParentProviderUUID: provider.ParentProviderUUID, + RootProviderUUID: provider.RootProviderUUID, + } + } + + return candidates +} + +func canSatisfyResources( + inventories map[string]Inventory, + usages map[string]int, + requested map[string]int, +) bool { + for resourceClass, amount := range requested { + inventory, ok := inventories[resourceClass] + if !ok { + return false + } + if capacity(inventory, usages[resourceClass]) < amount { + return false + } + } + + return true +} + +func toProviderSummaryResources( + inventories map[string]Inventory, + usages map[string]int, +) map[string]ProviderSummaryResource { + resources := map[string]ProviderSummaryResource{} + for resourceClass, inventory := range inventories { + used := usages[resourceClass] + resources[resourceClass] = ProviderSummaryResource{ + Capacity: capacity(inventory, used), + Used: used, + } + } + + return resources +} + +func capacity(inventory Inventory, used int) int { + available := inventory.Total - inventory.Reserved + return int(float32(available)*inventory.AllocationRatio) - used +} + +func copyIntMap(values map[string]int) map[string]int { + copied := map[string]int{} + for key, value := range values { + copied[key] = value + } + + return copied +} diff --git a/internal/app/placement/inventory.go b/internal/app/placement/inventory.go new file mode 100644 index 0000000..20a6a79 --- /dev/null +++ b/internal/app/placement/inventory.go @@ -0,0 +1,97 @@ +package placement + +func (s *Service) GetInventories( + resourceProviderUUID string, +) (Inventories, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Inventories{}, err + } + + return Inventories{ + ResourceProviderGeneration: provider.Generation, + Inventories: s.inventoryRepository.GetAll(resourceProviderUUID), + }, nil +} + +func (s *Service) UpdateInventories( + resourceProviderUUID string, + input UpdateInventories, +) (Inventories, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Inventories{}, err + } + + s.inventoryRepository.SetAll(resourceProviderUUID, input.Inventories) + + return Inventories{ + ResourceProviderGeneration: provider.Generation, + Inventories: s.inventoryRepository.GetAll(resourceProviderUUID), + }, nil +} + +func (s *Service) GetInventory( + resourceProviderUUID string, + resourceClass string, +) (UpdateInventory, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return UpdateInventory{}, err + } + + inventory, err := s.inventoryRepository.Get( + resourceProviderUUID, + resourceClass, + ) + if err != nil { + return UpdateInventory{}, err + } + + return UpdateInventory{ + ResourceProviderGeneration: provider.Generation, + Inventory: inventory, + }, nil +} + +func (s *Service) UpdateInventory( + resourceProviderUUID string, + resourceClass string, + input UpdateInventory, +) (UpdateInventory, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return UpdateInventory{}, err + } + + inventory := input.Inventory + inventory.ResourceClass = resourceClass + s.inventoryRepository.Set(resourceProviderUUID, inventory) + + return UpdateInventory{ + ResourceProviderGeneration: provider.Generation, + Inventory: inventory, + }, nil +} + +func (s *Service) DeleteInventories(resourceProviderUUID string) error { + _, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return err + } + + s.inventoryRepository.DeleteAll(resourceProviderUUID) + return nil +} + +func (s *Service) DeleteInventory( + resourceProviderUUID string, + resourceClass string, +) error { + _, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return err + } + + return s.inventoryRepository.Delete(resourceProviderUUID, resourceClass) +} diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go new file mode 100644 index 0000000..3f93521 --- /dev/null +++ b/internal/app/placement/repository.go @@ -0,0 +1,63 @@ +package placement + +type ResourceProviderRepository interface { + Create(provider ResourceProvider) ResourceProvider + List() []ResourceProvider + Get(uuid string) (ResourceProvider, error) + Delete(uuid string) error + Reset() +} + +type InventoryRepository interface { + SetAll(resourceProviderUUID string, inventories map[string]Inventory) + GetAll(resourceProviderUUID string) map[string]Inventory + Set(resourceProviderUUID string, inventory Inventory) + Get(resourceProviderUUID string, resourceClass string) (Inventory, error) + DeleteAll(resourceProviderUUID string) + Delete(resourceProviderUUID string, resourceClass string) error + Reset() +} + +type TraitRepository interface { + Set(resourceProviderUUID string, traits []string) + Get(resourceProviderUUID string) []string + Delete(resourceProviderUUID string) + Reset() +} + +type AggregateRepository interface { + Set(resourceProviderUUID string, aggregates []string) + Get(resourceProviderUUID string) []string + Delete(resourceProviderUUID string) + Reset() +} + +type UsageRepository interface { + Set(resourceProviderUUID string, usages map[string]int) + Get(resourceProviderUUID string) map[string]int + Delete(resourceProviderUUID string) + Reset() +} + +type AllocationRepository interface { + Set(resourceProviderUUID string, allocations map[string]Allocation) + Get(resourceProviderUUID string) map[string]Allocation + Delete(resourceProviderUUID string) + Reset() +} + +type ResourceClassRepository interface { + Create(resourceClass ResourceClass) ResourceClass + List() []ResourceClass + Get(name string) (ResourceClass, error) + Delete(name string) error + Reset() +} + +type TraitCatalogRepository interface { + Create(name string) + List() []string + Get(name string) (string, error) + Delete(name string) error + Reset() +} diff --git a/internal/app/placement/resource_class.go b/internal/app/placement/resource_class.go new file mode 100644 index 0000000..bb43422 --- /dev/null +++ b/internal/app/placement/resource_class.go @@ -0,0 +1,25 @@ +package placement + +func (s *Service) CreateResourceClass(name string) ResourceClass { + return s.resourceClassRepository.Create(ResourceClass{Name: name}) +} + +func (s *Service) EnsureResourceClass(name string) ResourceClass { + if resourceClass, err := s.resourceClassRepository.Get(name); err == nil { + return resourceClass + } + + return s.CreateResourceClass(name) +} + +func (s *Service) ListResourceClasses() []ResourceClass { + return s.resourceClassRepository.List() +} + +func (s *Service) GetResourceClass(name string) (ResourceClass, error) { + return s.resourceClassRepository.Get(name) +} + +func (s *Service) DeleteResourceClass(name string) error { + return s.resourceClassRepository.Delete(name) +} diff --git a/internal/app/placement/resource_provider.go b/internal/app/placement/resource_provider.go new file mode 100644 index 0000000..174aed7 --- /dev/null +++ b/internal/app/placement/resource_provider.go @@ -0,0 +1,47 @@ +package placement + +func (s *Service) CreateResourceProvider( + input CreateResourceProvider, +) ResourceProvider { + uuid := input.UUID + if uuid == "" { + uuid = "rp-" + s.idGen.Hex(16) + } + + rootProviderUUID := uuid + if input.ParentProviderUUID != "" { + rootProviderUUID = input.ParentProviderUUID + } + + provider := ResourceProvider{ + UUID: uuid, + Name: input.Name, + Generation: 0, + ParentProviderUUID: input.ParentProviderUUID, + RootProviderUUID: rootProviderUUID, + } + + return s.resourceProviderRepository.Create(provider) +} + +func (s *Service) ListResourceProviders() []ResourceProvider { + return s.resourceProviderRepository.List() +} + +func (s *Service) GetResourceProvider(uuid string) (ResourceProvider, error) { + return s.resourceProviderRepository.Get(uuid) +} + +func (s *Service) DeleteResourceProvider(uuid string) error { + err := s.resourceProviderRepository.Delete(uuid) + if err != nil { + return err + } + + s.inventoryRepository.DeleteAll(uuid) + s.traitRepository.Delete(uuid) + s.aggregateRepository.Delete(uuid) + s.usageRepository.Delete(uuid) + s.allocationRepository.Delete(uuid) + return nil +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go new file mode 100644 index 0000000..fc33f89 --- /dev/null +++ b/internal/app/placement/service.go @@ -0,0 +1,65 @@ +package placement + +import ( + "errors" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ( + ErrResourceProviderNotFound = errors.New("resource provider not found") + ErrInventoryNotFound = errors.New("inventory not found") + ErrResourceClassNotFound = errors.New("resource class not found") + ErrTraitNotFound = errors.New("trait not found") +) + +type Service struct { + resourceProviderRepository ResourceProviderRepository + inventoryRepository InventoryRepository + traitRepository TraitRepository + aggregateRepository AggregateRepository + usageRepository UsageRepository + allocationRepository AllocationRepository + resourceClassRepository ResourceClassRepository + traitCatalogRepository TraitCatalogRepository + idGen idgen.Generator +} + +type Repositories struct { + ResourceProviders ResourceProviderRepository + Inventories InventoryRepository + ProviderTraits TraitRepository + Aggregates AggregateRepository + Usages UsageRepository + Allocations AllocationRepository + ResourceClasses ResourceClassRepository + TraitCatalog TraitCatalogRepository +} + +func NewServiceWithRepositories( + repositories Repositories, + idGen idgen.Generator, +) *Service { + return &Service{ + resourceProviderRepository: repositories.ResourceProviders, + inventoryRepository: repositories.Inventories, + traitRepository: repositories.ProviderTraits, + aggregateRepository: repositories.Aggregates, + usageRepository: repositories.Usages, + allocationRepository: repositories.Allocations, + resourceClassRepository: repositories.ResourceClasses, + traitCatalogRepository: repositories.TraitCatalog, + idGen: idGen, + } +} + +func (s *Service) Reset() { + s.resourceProviderRepository.Reset() + s.inventoryRepository.Reset() + s.traitRepository.Reset() + s.aggregateRepository.Reset() + s.usageRepository.Reset() + s.allocationRepository.Reset() + s.resourceClassRepository.Reset() + s.traitCatalogRepository.Reset() +} diff --git a/internal/app/placement/trait.go b/internal/app/placement/trait.go new file mode 100644 index 0000000..909824e --- /dev/null +++ b/internal/app/placement/trait.go @@ -0,0 +1,40 @@ +package placement + +func (s *Service) GetTraits(resourceProviderUUID string) (Traits, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Traits{}, err + } + + return Traits{ + ResourceProviderGeneration: provider.Generation, + Traits: s.traitRepository.Get(resourceProviderUUID), + }, nil +} + +func (s *Service) UpdateTraits( + resourceProviderUUID string, + input UpdateTraits, +) (Traits, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Traits{}, err + } + + s.traitRepository.Set(resourceProviderUUID, input.Traits) + + return Traits{ + ResourceProviderGeneration: provider.Generation, + Traits: s.traitRepository.Get(resourceProviderUUID), + }, nil +} + +func (s *Service) DeleteTraits(resourceProviderUUID string) error { + _, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return err + } + + s.traitRepository.Delete(resourceProviderUUID) + return nil +} diff --git a/internal/app/placement/trait_catalog.go b/internal/app/placement/trait_catalog.go new file mode 100644 index 0000000..6aa919c --- /dev/null +++ b/internal/app/placement/trait_catalog.go @@ -0,0 +1,58 @@ +package placement + +import "strings" + +func (s *Service) CreateTrait(name string) (created bool) { + if _, err := s.traitCatalogRepository.Get(name); err == nil { + return false + } + + s.traitCatalogRepository.Create(name) + return true +} + +func (s *Service) ListTraits(nameFilter string) []string { + traits := s.traitCatalogRepository.List() + if nameFilter == "" { + return traits + } + + if after, ok := strings.CutPrefix(nameFilter, "startswith:"); ok { + return filterTraits(traits, func(trait string) bool { + return strings.HasPrefix(trait, after) + }) + } + + if after, ok := strings.CutPrefix(nameFilter, "in:"); ok { + allowed := map[string]bool{} + for _, name := range strings.Split(after, ",") { + allowed[name] = true + } + return filterTraits(traits, func(trait string) bool { + return allowed[trait] + }) + } + + return filterTraits(traits, func(trait string) bool { + return trait == nameFilter + }) +} + +func (s *Service) GetTrait(name string) (string, error) { + return s.traitCatalogRepository.Get(name) +} + +func (s *Service) DeleteTrait(name string) error { + return s.traitCatalogRepository.Delete(name) +} + +func filterTraits(traits []string, keep func(string) bool) []string { + filtered := []string{} + for _, trait := range traits { + if keep(trait) { + filtered = append(filtered, trait) + } + } + + return filtered +} diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go new file mode 100644 index 0000000..6becf84 --- /dev/null +++ b/internal/app/placement/types.go @@ -0,0 +1,103 @@ +package placement + +type ResourceProvider struct { + UUID string + Name string + Generation int + ParentProviderUUID string + RootProviderUUID string +} + +type CreateResourceProvider struct { + UUID string + Name string + ParentProviderUUID string +} + +type Inventory struct { + ResourceClass string + AllocationRatio float32 + MaxUnit int + MinUnit int + Reserved int + StepSize int + Total int +} + +type Inventories struct { + ResourceProviderGeneration int + Inventories map[string]Inventory +} + +type UpdateInventories struct { + ResourceProviderGeneration int + Inventories map[string]Inventory +} + +type UpdateInventory struct { + ResourceProviderGeneration int + Inventory Inventory +} + +type Traits struct { + ResourceProviderGeneration int + Traits []string +} + +type UpdateTraits struct { + ResourceProviderGeneration int + Traits []string +} + +type Aggregates struct { + ResourceProviderGeneration *int + Aggregates []string +} + +type UpdateAggregates struct { + ResourceProviderGeneration *int + Aggregates []string +} + +type Usages struct { + ResourceProviderGeneration int + Usages map[string]int +} + +type Allocation struct { + Resources map[string]int +} + +type Allocations struct { + ResourceProviderGeneration int + Allocations map[string]Allocation +} + +type AllocationCandidateQuery struct { + Resources map[string]int +} + +type AllocationCandidates struct { + AllocationRequests []AllocationRequest + ProviderSummaries map[string]ProviderSummary +} + +type AllocationRequest struct { + Allocations map[string]Allocation +} + +type ProviderSummary struct { + Resources map[string]ProviderSummaryResource + Traits []string + ParentProviderUUID string + RootProviderUUID string +} + +type ProviderSummaryResource struct { + Capacity int + Used int +} + +type ResourceClass struct { + Name string +} diff --git a/internal/app/placement/usage.go b/internal/app/placement/usage.go new file mode 100644 index 0000000..eaad31d --- /dev/null +++ b/internal/app/placement/usage.go @@ -0,0 +1,13 @@ +package placement + +func (s *Service) GetUsages(resourceProviderUUID string) (Usages, error) { + provider, err := s.resourceProviderRepository.Get(resourceProviderUUID) + if err != nil { + return Usages{}, err + } + + return Usages{ + ResourceProviderGeneration: provider.Generation, + Usages: s.usageRepository.Get(resourceProviderUUID), + }, nil +} diff --git a/internal/store/placement/memory_aggregate_repository.go b/internal/store/placement/memory_aggregate_repository.go new file mode 100644 index 0000000..aea742b --- /dev/null +++ b/internal/store/placement/memory_aggregate_repository.go @@ -0,0 +1,47 @@ +package placement + +import "sync" + +type MemoryAggregateRepository struct { + mu sync.RWMutex + aggregates map[string][]string +} + +func NewMemoryAggregateRepository() *MemoryAggregateRepository { + return &MemoryAggregateRepository{ + aggregates: map[string][]string{}, + } +} + +func (r *MemoryAggregateRepository) Set( + resourceProviderUUID string, + aggregates []string, +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.aggregates[resourceProviderUUID] = append([]string{}, aggregates...) +} + +func (r *MemoryAggregateRepository) Get( + resourceProviderUUID string, +) []string { + r.mu.RLock() + defer r.mu.RUnlock() + + return append([]string{}, r.aggregates[resourceProviderUUID]...) +} + +func (r *MemoryAggregateRepository) Delete(resourceProviderUUID string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.aggregates, resourceProviderUUID) +} + +func (r *MemoryAggregateRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.aggregates = map[string][]string{} +} diff --git a/internal/store/placement/memory_allocation_repository.go b/internal/store/placement/memory_allocation_repository.go new file mode 100644 index 0000000..a0a193b --- /dev/null +++ b/internal/store/placement/memory_allocation_repository.go @@ -0,0 +1,73 @@ +package placement + +import ( + "sync" + + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +type MemoryAllocationRepository struct { + mu sync.RWMutex + allocations map[string]map[string]appplacement.Allocation +} + +func NewMemoryAllocationRepository() *MemoryAllocationRepository { + return &MemoryAllocationRepository{ + allocations: map[string]map[string]appplacement.Allocation{}, + } +} + +func (r *MemoryAllocationRepository) Set( + resourceProviderUUID string, + allocations map[string]appplacement.Allocation, +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.allocations[resourceProviderUUID] = copyAllocations(allocations) +} + +func (r *MemoryAllocationRepository) Get( + resourceProviderUUID string, +) map[string]appplacement.Allocation { + r.mu.RLock() + defer r.mu.RUnlock() + + return copyAllocations(r.allocations[resourceProviderUUID]) +} + +func (r *MemoryAllocationRepository) Delete(resourceProviderUUID string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.allocations, resourceProviderUUID) +} + +func (r *MemoryAllocationRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.allocations = map[string]map[string]appplacement.Allocation{} +} + +func copyAllocations( + allocations map[string]appplacement.Allocation, +) map[string]appplacement.Allocation { + copied := map[string]appplacement.Allocation{} + for consumerID, allocation := range allocations { + copied[consumerID] = appplacement.Allocation{ + Resources: copyResources(allocation.Resources), + } + } + + return copied +} + +func copyResources(resources map[string]int) map[string]int { + copied := map[string]int{} + for resourceClass, amount := range resources { + copied[resourceClass] = amount + } + + return copied +} diff --git a/internal/store/placement/memory_inventory_repository.go b/internal/store/placement/memory_inventory_repository.go new file mode 100644 index 0000000..ef52484 --- /dev/null +++ b/internal/store/placement/memory_inventory_repository.go @@ -0,0 +1,115 @@ +package placement + +import ( + "sync" + + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +type MemoryInventoryRepository struct { + mu sync.RWMutex + inventories map[string]map[string]appplacement.Inventory +} + +func NewMemoryInventoryRepository() *MemoryInventoryRepository { + return &MemoryInventoryRepository{ + inventories: map[string]map[string]appplacement.Inventory{}, + } +} + +func (r *MemoryInventoryRepository) SetAll( + resourceProviderUUID string, + inventories map[string]appplacement.Inventory, +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.inventories[resourceProviderUUID] = copyInventories(inventories) +} + +func (r *MemoryInventoryRepository) GetAll( + resourceProviderUUID string, +) map[string]appplacement.Inventory { + r.mu.RLock() + defer r.mu.RUnlock() + + return copyInventories(r.inventories[resourceProviderUUID]) +} + +func (r *MemoryInventoryRepository) Set( + resourceProviderUUID string, + inventory appplacement.Inventory, +) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.inventories[resourceProviderUUID]; !ok { + r.inventories[resourceProviderUUID] = map[string]appplacement.Inventory{} + } + r.inventories[resourceProviderUUID][inventory.ResourceClass] = inventory +} + +func (r *MemoryInventoryRepository) Get( + resourceProviderUUID string, + resourceClass string, +) (appplacement.Inventory, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + inventories, ok := r.inventories[resourceProviderUUID] + if !ok { + return appplacement.Inventory{}, appplacement.ErrInventoryNotFound + } + + inventory, ok := inventories[resourceClass] + if !ok { + return appplacement.Inventory{}, appplacement.ErrInventoryNotFound + } + + return inventory, nil +} + +func (r *MemoryInventoryRepository) DeleteAll(resourceProviderUUID string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.inventories, resourceProviderUUID) +} + +func (r *MemoryInventoryRepository) Delete( + resourceProviderUUID string, + resourceClass string, +) error { + r.mu.Lock() + defer r.mu.Unlock() + + inventories, ok := r.inventories[resourceProviderUUID] + if !ok { + return appplacement.ErrInventoryNotFound + } + if _, ok := inventories[resourceClass]; !ok { + return appplacement.ErrInventoryNotFound + } + + delete(inventories, resourceClass) + return nil +} + +func (r *MemoryInventoryRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.inventories = map[string]map[string]appplacement.Inventory{} +} + +func copyInventories( + inventories map[string]appplacement.Inventory, +) map[string]appplacement.Inventory { + copied := map[string]appplacement.Inventory{} + for resourceClass, inventory := range inventories { + inventory.ResourceClass = resourceClass + copied[resourceClass] = inventory + } + + return copied +} diff --git a/internal/store/placement/memory_resource_class_repository.go b/internal/store/placement/memory_resource_class_repository.go new file mode 100644 index 0000000..2a9f305 --- /dev/null +++ b/internal/store/placement/memory_resource_class_repository.go @@ -0,0 +1,87 @@ +package placement + +import ( + "sync" + + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +type MemoryResourceClassRepository struct { + mu sync.RWMutex + names []string + classes map[string]appplacement.ResourceClass +} + +func NewMemoryResourceClassRepository() *MemoryResourceClassRepository { + return &MemoryResourceClassRepository{ + names: []string{}, + classes: map[string]appplacement.ResourceClass{}, + } +} + +func (r *MemoryResourceClassRepository) Create( + resourceClass appplacement.ResourceClass, +) appplacement.ResourceClass { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.classes[resourceClass.Name]; !ok { + r.names = append(r.names, resourceClass.Name) + } + r.classes[resourceClass.Name] = resourceClass + + return resourceClass +} + +func (r *MemoryResourceClassRepository) List() []appplacement.ResourceClass { + r.mu.RLock() + defer r.mu.RUnlock() + + classes := make([]appplacement.ResourceClass, 0, len(r.names)) + for _, name := range r.names { + classes = append(classes, r.classes[name]) + } + + return classes +} + +func (r *MemoryResourceClassRepository) Get( + name string, +) (appplacement.ResourceClass, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + resourceClass, ok := r.classes[name] + if !ok { + return appplacement.ResourceClass{}, appplacement.ErrResourceClassNotFound + } + + return resourceClass, nil +} + +func (r *MemoryResourceClassRepository) Delete(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.classes[name]; !ok { + return appplacement.ErrResourceClassNotFound + } + + delete(r.classes, name) + for index, currentName := range r.names { + if currentName == name { + r.names = append(r.names[:index], r.names[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryResourceClassRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.names = []string{} + r.classes = map[string]appplacement.ResourceClass{} +} diff --git a/internal/store/placement/memory_resource_provider_repository.go b/internal/store/placement/memory_resource_provider_repository.go new file mode 100644 index 0000000..36f0c6d --- /dev/null +++ b/internal/store/placement/memory_resource_provider_repository.go @@ -0,0 +1,87 @@ +package placement + +import ( + "sync" + + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +type MemoryResourceProviderRepository struct { + mu sync.RWMutex + uuids []string + providers map[string]appplacement.ResourceProvider +} + +func NewMemoryResourceProviderRepository() *MemoryResourceProviderRepository { + return &MemoryResourceProviderRepository{ + uuids: []string{}, + providers: map[string]appplacement.ResourceProvider{}, + } +} + +func (r *MemoryResourceProviderRepository) Create( + provider appplacement.ResourceProvider, +) appplacement.ResourceProvider { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.providers[provider.UUID]; !ok { + r.uuids = append(r.uuids, provider.UUID) + } + r.providers[provider.UUID] = provider + + return provider +} + +func (r *MemoryResourceProviderRepository) List() []appplacement.ResourceProvider { + r.mu.RLock() + defer r.mu.RUnlock() + + providers := make([]appplacement.ResourceProvider, 0, len(r.uuids)) + for _, uuid := range r.uuids { + providers = append(providers, r.providers[uuid]) + } + + return providers +} + +func (r *MemoryResourceProviderRepository) Get( + uuid string, +) (appplacement.ResourceProvider, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + provider, ok := r.providers[uuid] + if !ok { + return appplacement.ResourceProvider{}, appplacement.ErrResourceProviderNotFound + } + + return provider, nil +} + +func (r *MemoryResourceProviderRepository) Delete(uuid string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.providers[uuid]; !ok { + return appplacement.ErrResourceProviderNotFound + } + + delete(r.providers, uuid) + for index, currentUUID := range r.uuids { + if currentUUID == uuid { + r.uuids = append(r.uuids[:index], r.uuids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryResourceProviderRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.uuids = []string{} + r.providers = map[string]appplacement.ResourceProvider{} +} diff --git a/internal/store/placement/memory_trait_catalog_repository.go b/internal/store/placement/memory_trait_catalog_repository.go new file mode 100644 index 0000000..80e93af --- /dev/null +++ b/internal/store/placement/memory_trait_catalog_repository.go @@ -0,0 +1,81 @@ +package placement + +import ( + "sync" + + appplacement "github.com/JSYoo5B/SandStack/internal/app/placement" +) + +type MemoryTraitCatalogRepository struct { + mu sync.RWMutex + names []string + traits map[string]string +} + +func NewMemoryTraitCatalogRepository() *MemoryTraitCatalogRepository { + return &MemoryTraitCatalogRepository{ + names: []string{}, + traits: map[string]string{}, + } +} + +func (r *MemoryTraitCatalogRepository) Create(name string) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.traits[name]; !ok { + r.names = append(r.names, name) + } + r.traits[name] = name +} + +func (r *MemoryTraitCatalogRepository) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + traits := make([]string, 0, len(r.names)) + for _, name := range r.names { + traits = append(traits, r.traits[name]) + } + + return traits +} + +func (r *MemoryTraitCatalogRepository) Get(name string) (string, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + trait, ok := r.traits[name] + if !ok { + return "", appplacement.ErrTraitNotFound + } + + return trait, nil +} + +func (r *MemoryTraitCatalogRepository) Delete(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.traits[name]; !ok { + return appplacement.ErrTraitNotFound + } + + delete(r.traits, name) + for index, currentName := range r.names { + if currentName == name { + r.names = append(r.names[:index], r.names[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryTraitCatalogRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.names = []string{} + r.traits = map[string]string{} +} diff --git a/internal/store/placement/memory_trait_repository.go b/internal/store/placement/memory_trait_repository.go new file mode 100644 index 0000000..e63244b --- /dev/null +++ b/internal/store/placement/memory_trait_repository.go @@ -0,0 +1,45 @@ +package placement + +import "sync" + +type MemoryTraitRepository struct { + mu sync.RWMutex + traits map[string][]string +} + +func NewMemoryTraitRepository() *MemoryTraitRepository { + return &MemoryTraitRepository{ + traits: map[string][]string{}, + } +} + +func (r *MemoryTraitRepository) Set( + resourceProviderUUID string, + traits []string, +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.traits[resourceProviderUUID] = append([]string{}, traits...) +} + +func (r *MemoryTraitRepository) Get(resourceProviderUUID string) []string { + r.mu.RLock() + defer r.mu.RUnlock() + + return append([]string{}, r.traits[resourceProviderUUID]...) +} + +func (r *MemoryTraitRepository) Delete(resourceProviderUUID string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.traits, resourceProviderUUID) +} + +func (r *MemoryTraitRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.traits = map[string][]string{} +} diff --git a/internal/store/placement/memory_usage_repository.go b/internal/store/placement/memory_usage_repository.go new file mode 100644 index 0000000..1f02653 --- /dev/null +++ b/internal/store/placement/memory_usage_repository.go @@ -0,0 +1,54 @@ +package placement + +import "sync" + +type MemoryUsageRepository struct { + mu sync.RWMutex + usages map[string]map[string]int +} + +func NewMemoryUsageRepository() *MemoryUsageRepository { + return &MemoryUsageRepository{ + usages: map[string]map[string]int{}, + } +} + +func (r *MemoryUsageRepository) Set( + resourceProviderUUID string, + usages map[string]int, +) { + r.mu.Lock() + defer r.mu.Unlock() + + r.usages[resourceProviderUUID] = copyUsages(usages) +} + +func (r *MemoryUsageRepository) Get(resourceProviderUUID string) map[string]int { + r.mu.RLock() + defer r.mu.RUnlock() + + return copyUsages(r.usages[resourceProviderUUID]) +} + +func (r *MemoryUsageRepository) Delete(resourceProviderUUID string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.usages, resourceProviderUUID) +} + +func (r *MemoryUsageRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.usages = map[string]map[string]int{} +} + +func copyUsages(usages map[string]int) map[string]int { + copied := map[string]int{} + for resourceClass, used := range usages { + copied[resourceClass] = used + } + + return copied +}