From 9b45ceaeff017704591359b317e96944801badcf Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:58:14 +0900 Subject: [PATCH 01/11] feat(placement): add resource provider endpoints --- internal/api/placement/resource_provider.go | 77 +++++++++++ .../api/placement/resource_provider_dto.go | 56 ++++++++ .../api/placement/resource_provider_test.go | 121 ++++++++++++++++++ internal/api/placement/router.go | 41 +++++- internal/api/router.go | 9 +- internal/app/placement/repository.go | 9 ++ internal/app/placement/service.go | 64 +++++++++ internal/app/placement/types.go | 15 +++ .../memory_resource_provider_repository.go | 87 +++++++++++++ 9 files changed, 476 insertions(+), 3 deletions(-) create mode 100644 internal/api/placement/resource_provider.go create mode 100644 internal/api/placement/resource_provider_dto.go create mode 100644 internal/api/placement/resource_provider_test.go create mode 100644 internal/app/placement/repository.go create mode 100644 internal/app/placement/service.go create mode 100644 internal/app/placement/types.go create mode 100644 internal/store/placement/memory_resource_provider_repository.go 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..b85dcef 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -3,25 +3,62 @@ 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( + storeplacement.NewMemoryResourceProviderRepository(), + 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, + ) return router } diff --git a/internal/api/router.go b/internal/api/router.go index 0c62ab8..819ae12 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,17 @@ func NewRouter(cfg config.Config) http.Handler { clock.Wall(), idgen.Random(), ) + placementService := appplacement.NewServiceWithRepositories( + storeplacement.NewMemoryResourceProviderRepository(), + 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 +105,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/repository.go b/internal/app/placement/repository.go new file mode 100644 index 0000000..0067e34 --- /dev/null +++ b/internal/app/placement/repository.go @@ -0,0 +1,9 @@ +package placement + +type ResourceProviderRepository interface { + Create(provider ResourceProvider) ResourceProvider + List() []ResourceProvider + Get(uuid string) (ResourceProvider, error) + Delete(uuid string) error + Reset() +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go new file mode 100644 index 0000000..fa3c113 --- /dev/null +++ b/internal/app/placement/service.go @@ -0,0 +1,64 @@ +package placement + +import ( + "errors" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ErrResourceProviderNotFound = errors.New("resource provider not found") + +type Service struct { + resourceProviderRepository ResourceProviderRepository + idGen idgen.Generator +} + +func NewServiceWithRepositories( + resourceProviderRepository ResourceProviderRepository, + idGen idgen.Generator, +) *Service { + return &Service{ + resourceProviderRepository: resourceProviderRepository, + idGen: idGen, + } +} + +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 { + return s.resourceProviderRepository.Delete(uuid) +} + +func (s *Service) Reset() { + s.resourceProviderRepository.Reset() +} diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go new file mode 100644 index 0000000..47830dd --- /dev/null +++ b/internal/app/placement/types.go @@ -0,0 +1,15 @@ +package placement + +type ResourceProvider struct { + UUID string + Name string + Generation int + ParentProviderUUID string + RootProviderUUID string +} + +type CreateResourceProvider struct { + UUID string + Name string + ParentProviderUUID string +} 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{} +} From 8e6b5e91edce0a77a11fc1fc539524fa76417cd7 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:01:31 +0900 Subject: [PATCH 02/11] feat(placement): add inventory endpoints --- internal/api/placement/inventory.go | 122 +++++++++++ internal/api/placement/inventory_dto.go | 80 +++++++ internal/api/placement/inventory_test.go | 201 ++++++++++++++++++ internal/api/placement/router.go | 25 +++ internal/api/router.go | 1 + internal/app/placement/repository.go | 10 + internal/app/placement/service.go | 113 +++++++++- internal/app/placement/types.go | 25 +++ .../placement/memory_inventory_repository.go | 115 ++++++++++ 9 files changed, 690 insertions(+), 2 deletions(-) create mode 100644 internal/api/placement/inventory.go create mode 100644 internal/api/placement/inventory_dto.go create mode 100644 internal/api/placement/inventory_test.go create mode 100644 internal/store/placement/memory_inventory_repository.go 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/router.go b/internal/api/placement/router.go index b85dcef..5d1fe44 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -31,6 +31,7 @@ func NewHandler(cfg config.Config) Handler { cfg, appplacement.NewServiceWithRepositories( storeplacement.NewMemoryResourceProviderRepository(), + storeplacement.NewMemoryInventoryRepository(), idgen.Random(), ), ) @@ -59,6 +60,30 @@ func (h Handler) Router() http.Handler { "/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, + ) return router } diff --git a/internal/api/router.go b/internal/api/router.go index 819ae12..d8d630f 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -85,6 +85,7 @@ func NewRouter(cfg config.Config) http.Handler { ) placementService := appplacement.NewServiceWithRepositories( storeplacement.NewMemoryResourceProviderRepository(), + storeplacement.NewMemoryInventoryRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index 0067e34..29453fd 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -7,3 +7,13 @@ type ResourceProviderRepository interface { 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() +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index fa3c113..190ff89 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -6,19 +6,25 @@ import ( "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) -var ErrResourceProviderNotFound = errors.New("resource provider not found") +var ( + ErrResourceProviderNotFound = errors.New("resource provider not found") + ErrInventoryNotFound = errors.New("inventory not found") +) type Service struct { resourceProviderRepository ResourceProviderRepository + inventoryRepository InventoryRepository idGen idgen.Generator } func NewServiceWithRepositories( resourceProviderRepository ResourceProviderRepository, + inventoryRepository InventoryRepository, idGen idgen.Generator, ) *Service { return &Service{ resourceProviderRepository: resourceProviderRepository, + inventoryRepository: inventoryRepository, idGen: idGen, } } @@ -56,9 +62,112 @@ func (s *Service) GetResourceProvider(uuid string) (ResourceProvider, error) { } func (s *Service) DeleteResourceProvider(uuid string) error { - return s.resourceProviderRepository.Delete(uuid) + err := s.resourceProviderRepository.Delete(uuid) + if err != nil { + return err + } + + s.inventoryRepository.DeleteAll(uuid) + return nil +} + +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) } func (s *Service) Reset() { s.resourceProviderRepository.Reset() + s.inventoryRepository.Reset() } diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index 47830dd..ee28096 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -13,3 +13,28 @@ type CreateResourceProvider struct { 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 +} 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 +} From eb66d79e542477f6237da01e366f2bf5c879cbbc Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:03:07 +0900 Subject: [PATCH 03/11] feat(placement): add trait endpoints --- internal/api/placement/router.go | 13 ++ internal/api/placement/trait.go | 51 ++++++++ internal/api/placement/trait_dto.go | 15 +++ internal/api/placement/trait_test.go | 114 ++++++++++++++++++ internal/api/router.go | 1 + internal/app/placement/repository.go | 7 ++ internal/app/placement/service.go | 44 +++++++ internal/app/placement/types.go | 10 ++ .../placement/memory_trait_repository.go | 45 +++++++ 9 files changed, 300 insertions(+) create mode 100644 internal/api/placement/trait.go create mode 100644 internal/api/placement/trait_dto.go create mode 100644 internal/api/placement/trait_test.go create mode 100644 internal/store/placement/memory_trait_repository.go diff --git a/internal/api/placement/router.go b/internal/api/placement/router.go index 5d1fe44..ea6ab6f 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -32,6 +32,7 @@ func NewHandler(cfg config.Config) Handler { appplacement.NewServiceWithRepositories( storeplacement.NewMemoryResourceProviderRepository(), storeplacement.NewMemoryInventoryRepository(), + storeplacement.NewMemoryTraitRepository(), idgen.Random(), ), ) @@ -84,6 +85,18 @@ func (h Handler) Router() http.Handler { "/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, + ) 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_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/router.go b/internal/api/router.go index d8d630f..866b4aa 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -86,6 +86,7 @@ func NewRouter(cfg config.Config) http.Handler { placementService := appplacement.NewServiceWithRepositories( storeplacement.NewMemoryResourceProviderRepository(), storeplacement.NewMemoryInventoryRepository(), + storeplacement.NewMemoryTraitRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index 29453fd..0958a0f 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -17,3 +17,10 @@ type InventoryRepository interface { Delete(resourceProviderUUID string, resourceClass string) error Reset() } + +type TraitRepository interface { + Set(resourceProviderUUID string, traits []string) + Get(resourceProviderUUID string) []string + Delete(resourceProviderUUID string) + Reset() +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 190ff89..699dacb 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -14,17 +14,20 @@ var ( type Service struct { resourceProviderRepository ResourceProviderRepository inventoryRepository InventoryRepository + traitRepository TraitRepository idGen idgen.Generator } func NewServiceWithRepositories( resourceProviderRepository ResourceProviderRepository, inventoryRepository InventoryRepository, + traitRepository TraitRepository, idGen idgen.Generator, ) *Service { return &Service{ resourceProviderRepository: resourceProviderRepository, inventoryRepository: inventoryRepository, + traitRepository: traitRepository, idGen: idGen, } } @@ -68,6 +71,7 @@ func (s *Service) DeleteResourceProvider(uuid string) error { } s.inventoryRepository.DeleteAll(uuid) + s.traitRepository.Delete(uuid) return nil } @@ -167,7 +171,47 @@ func (s *Service) DeleteInventory( return s.inventoryRepository.Delete(resourceProviderUUID, resourceClass) } +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 +} + func (s *Service) Reset() { s.resourceProviderRepository.Reset() s.inventoryRepository.Reset() + s.traitRepository.Reset() } diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index ee28096..2105659 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -38,3 +38,13 @@ type UpdateInventory struct { ResourceProviderGeneration int Inventory Inventory } + +type Traits struct { + ResourceProviderGeneration int + Traits []string +} + +type UpdateTraits struct { + ResourceProviderGeneration int + Traits []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{} +} From fb98bbb8c5119a04487191dc75f2bb8c22bd3a0d Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:04:47 +0900 Subject: [PATCH 04/11] feat(placement): add aggregate endpoints --- internal/api/placement/aggregate.go | 42 ++++++++++ internal/api/placement/aggregate_dto.go | 17 ++++ internal/api/placement/aggregate_test.go | 80 +++++++++++++++++++ internal/api/placement/router.go | 9 +++ internal/api/router.go | 1 + internal/app/placement/repository.go | 7 ++ internal/app/placement/service.go | 44 ++++++++++ internal/app/placement/types.go | 10 +++ .../placement/memory_aggregate_repository.go | 47 +++++++++++ 9 files changed, 257 insertions(+) create mode 100644 internal/api/placement/aggregate.go create mode 100644 internal/api/placement/aggregate_dto.go create mode 100644 internal/api/placement/aggregate_test.go create mode 100644 internal/store/placement/memory_aggregate_repository.go 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/router.go b/internal/api/placement/router.go index ea6ab6f..b0d46f4 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -33,6 +33,7 @@ func NewHandler(cfg config.Config) Handler { storeplacement.NewMemoryResourceProviderRepository(), storeplacement.NewMemoryInventoryRepository(), storeplacement.NewMemoryTraitRepository(), + storeplacement.NewMemoryAggregateRepository(), idgen.Random(), ), ) @@ -97,6 +98,14 @@ func (h Handler) Router() http.Handler { "/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, + ) return router } diff --git a/internal/api/router.go b/internal/api/router.go index 866b4aa..f7d9d62 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -87,6 +87,7 @@ func NewRouter(cfg config.Config) http.Handler { storeplacement.NewMemoryResourceProviderRepository(), storeplacement.NewMemoryInventoryRepository(), storeplacement.NewMemoryTraitRepository(), + storeplacement.NewMemoryAggregateRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index 0958a0f..fbeb596 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -24,3 +24,10 @@ type TraitRepository interface { Delete(resourceProviderUUID string) Reset() } + +type AggregateRepository interface { + Set(resourceProviderUUID string, aggregates []string) + Get(resourceProviderUUID string) []string + Delete(resourceProviderUUID string) + Reset() +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 699dacb..7812acc 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -15,6 +15,7 @@ type Service struct { resourceProviderRepository ResourceProviderRepository inventoryRepository InventoryRepository traitRepository TraitRepository + aggregateRepository AggregateRepository idGen idgen.Generator } @@ -22,12 +23,14 @@ func NewServiceWithRepositories( resourceProviderRepository ResourceProviderRepository, inventoryRepository InventoryRepository, traitRepository TraitRepository, + aggregateRepository AggregateRepository, idGen idgen.Generator, ) *Service { return &Service{ resourceProviderRepository: resourceProviderRepository, inventoryRepository: inventoryRepository, traitRepository: traitRepository, + aggregateRepository: aggregateRepository, idGen: idGen, } } @@ -72,6 +75,7 @@ func (s *Service) DeleteResourceProvider(uuid string) error { s.inventoryRepository.DeleteAll(uuid) s.traitRepository.Delete(uuid) + s.aggregateRepository.Delete(uuid) return nil } @@ -210,8 +214,48 @@ func (s *Service) DeleteTraits(resourceProviderUUID string) error { return nil } +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 (s *Service) Reset() { s.resourceProviderRepository.Reset() s.inventoryRepository.Reset() s.traitRepository.Reset() + s.aggregateRepository.Reset() +} + +func intPtr(value int) *int { + return &value } diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index 2105659..3dc6b47 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -48,3 +48,13 @@ type UpdateTraits struct { ResourceProviderGeneration int Traits []string } + +type Aggregates struct { + ResourceProviderGeneration *int + Aggregates []string +} + +type UpdateAggregates struct { + ResourceProviderGeneration *int + Aggregates []string +} 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{} +} From 7f76d1c592d7101bd08791e68b9e7c8f78efaf3f Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:06:10 +0900 Subject: [PATCH 05/11] feat(placement): add usage endpoint --- internal/api/placement/router.go | 5 ++ internal/api/placement/usage.go | 19 ++++++ internal/api/placement/usage_dto.go | 15 +++++ internal/api/placement/usage_test.go | 63 +++++++++++++++++++ internal/api/router.go | 1 + internal/app/placement/repository.go | 7 +++ internal/app/placement/service.go | 17 +++++ internal/app/placement/types.go | 5 ++ .../placement/memory_usage_repository.go | 54 ++++++++++++++++ 9 files changed, 186 insertions(+) create mode 100644 internal/api/placement/usage.go create mode 100644 internal/api/placement/usage_dto.go create mode 100644 internal/api/placement/usage_test.go create mode 100644 internal/store/placement/memory_usage_repository.go diff --git a/internal/api/placement/router.go b/internal/api/placement/router.go index b0d46f4..dd8f3bc 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -34,6 +34,7 @@ func NewHandler(cfg config.Config) Handler { storeplacement.NewMemoryInventoryRepository(), storeplacement.NewMemoryTraitRepository(), storeplacement.NewMemoryAggregateRepository(), + storeplacement.NewMemoryUsageRepository(), idgen.Random(), ), ) @@ -106,6 +107,10 @@ func (h Handler) Router() http.Handler { "/resource_providers/{resource_provider_uuid}/aggregates", h.updateAggregates, ) + router.Get( + "/resource_providers/{resource_provider_uuid}/usages", + h.getUsages, + ) return router } 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 f7d9d62..d7e6482 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -88,6 +88,7 @@ func NewRouter(cfg config.Config) http.Handler { storeplacement.NewMemoryInventoryRepository(), storeplacement.NewMemoryTraitRepository(), storeplacement.NewMemoryAggregateRepository(), + storeplacement.NewMemoryUsageRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index fbeb596..a0e5794 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -31,3 +31,10 @@ type AggregateRepository interface { Delete(resourceProviderUUID string) Reset() } + +type UsageRepository interface { + Set(resourceProviderUUID string, usages map[string]int) + Get(resourceProviderUUID string) map[string]int + Delete(resourceProviderUUID string) + Reset() +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 7812acc..e2eee85 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -16,6 +16,7 @@ type Service struct { inventoryRepository InventoryRepository traitRepository TraitRepository aggregateRepository AggregateRepository + usageRepository UsageRepository idGen idgen.Generator } @@ -24,6 +25,7 @@ func NewServiceWithRepositories( inventoryRepository InventoryRepository, traitRepository TraitRepository, aggregateRepository AggregateRepository, + usageRepository UsageRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -31,6 +33,7 @@ func NewServiceWithRepositories( inventoryRepository: inventoryRepository, traitRepository: traitRepository, aggregateRepository: aggregateRepository, + usageRepository: usageRepository, idGen: idGen, } } @@ -76,6 +79,7 @@ func (s *Service) DeleteResourceProvider(uuid string) error { s.inventoryRepository.DeleteAll(uuid) s.traitRepository.Delete(uuid) s.aggregateRepository.Delete(uuid) + s.usageRepository.Delete(uuid) return nil } @@ -249,11 +253,24 @@ func (s *Service) UpdateAggregates( }, nil } +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 +} + func (s *Service) Reset() { s.resourceProviderRepository.Reset() s.inventoryRepository.Reset() s.traitRepository.Reset() s.aggregateRepository.Reset() + s.usageRepository.Reset() } func intPtr(value int) *int { diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index 3dc6b47..f161ccf 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -58,3 +58,8 @@ type UpdateAggregates struct { ResourceProviderGeneration *int Aggregates []string } + +type Usages struct { + ResourceProviderGeneration int + Usages map[string]int +} 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 +} From 463917ad829db63fbb6bd287002d22007aa0823a Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:08:14 +0900 Subject: [PATCH 06/11] refactor(placement): split service methods by resource --- internal/app/placement/aggregate.go | 40 ++++ internal/app/placement/inventory.go | 97 ++++++++ internal/app/placement/resource_provider.go | 46 ++++ internal/app/placement/service.go | 231 -------------------- internal/app/placement/trait.go | 40 ++++ internal/app/placement/usage.go | 13 ++ 6 files changed, 236 insertions(+), 231 deletions(-) create mode 100644 internal/app/placement/aggregate.go create mode 100644 internal/app/placement/inventory.go create mode 100644 internal/app/placement/resource_provider.go create mode 100644 internal/app/placement/trait.go create mode 100644 internal/app/placement/usage.go 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/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/resource_provider.go b/internal/app/placement/resource_provider.go new file mode 100644 index 0000000..0883d9b --- /dev/null +++ b/internal/app/placement/resource_provider.go @@ -0,0 +1,46 @@ +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) + return nil +} diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index e2eee85..45207a4 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -38,233 +38,6 @@ func NewServiceWithRepositories( } } -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) - return nil -} - -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) -} - -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 -} - -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 (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 -} - func (s *Service) Reset() { s.resourceProviderRepository.Reset() s.inventoryRepository.Reset() @@ -272,7 +45,3 @@ func (s *Service) Reset() { s.aggregateRepository.Reset() s.usageRepository.Reset() } - -func intPtr(value int) *int { - return &value -} 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/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 +} From e23318d5c4093c332b4154273a2f02755e011996 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:09:41 +0900 Subject: [PATCH 07/11] feat(placement): add allocation lookup endpoint --- internal/api/placement/allocation.go | 19 +++++ internal/api/placement/allocation_dto.go | 28 +++++++ internal/api/placement/allocation_test.go | 66 +++++++++++++++++ internal/api/placement/router.go | 5 ++ internal/api/router.go | 1 + internal/app/placement/allocation.go | 17 +++++ internal/app/placement/repository.go | 7 ++ internal/app/placement/resource_provider.go | 1 + internal/app/placement/service.go | 4 + internal/app/placement/types.go | 9 +++ .../placement/memory_allocation_repository.go | 73 +++++++++++++++++++ 11 files changed, 230 insertions(+) create mode 100644 internal/api/placement/allocation.go create mode 100644 internal/api/placement/allocation_dto.go create mode 100644 internal/api/placement/allocation_test.go create mode 100644 internal/app/placement/allocation.go create mode 100644 internal/store/placement/memory_allocation_repository.go 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_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/router.go b/internal/api/placement/router.go index dd8f3bc..b156a75 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -35,6 +35,7 @@ func NewHandler(cfg config.Config) Handler { storeplacement.NewMemoryTraitRepository(), storeplacement.NewMemoryAggregateRepository(), storeplacement.NewMemoryUsageRepository(), + storeplacement.NewMemoryAllocationRepository(), idgen.Random(), ), ) @@ -111,6 +112,10 @@ func (h Handler) Router() http.Handler { "/resource_providers/{resource_provider_uuid}/usages", h.getUsages, ) + router.Get( + "/resource_providers/{resource_provider_uuid}/allocations", + h.getAllocations, + ) return router } diff --git a/internal/api/router.go b/internal/api/router.go index d7e6482..3166944 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -89,6 +89,7 @@ func NewRouter(cfg config.Config) http.Handler { storeplacement.NewMemoryTraitRepository(), storeplacement.NewMemoryAggregateRepository(), storeplacement.NewMemoryUsageRepository(), + storeplacement.NewMemoryAllocationRepository(), idgen.Random(), ) 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/repository.go b/internal/app/placement/repository.go index a0e5794..f574f87 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -38,3 +38,10 @@ type UsageRepository interface { Delete(resourceProviderUUID string) Reset() } + +type AllocationRepository interface { + Set(resourceProviderUUID string, allocations map[string]Allocation) + Get(resourceProviderUUID string) map[string]Allocation + Delete(resourceProviderUUID string) + Reset() +} diff --git a/internal/app/placement/resource_provider.go b/internal/app/placement/resource_provider.go index 0883d9b..174aed7 100644 --- a/internal/app/placement/resource_provider.go +++ b/internal/app/placement/resource_provider.go @@ -42,5 +42,6 @@ func (s *Service) DeleteResourceProvider(uuid string) error { 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 index 45207a4..92e112b 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -17,6 +17,7 @@ type Service struct { traitRepository TraitRepository aggregateRepository AggregateRepository usageRepository UsageRepository + allocationRepository AllocationRepository idGen idgen.Generator } @@ -26,6 +27,7 @@ func NewServiceWithRepositories( traitRepository TraitRepository, aggregateRepository AggregateRepository, usageRepository UsageRepository, + allocationRepository AllocationRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -34,6 +36,7 @@ func NewServiceWithRepositories( traitRepository: traitRepository, aggregateRepository: aggregateRepository, usageRepository: usageRepository, + allocationRepository: allocationRepository, idGen: idGen, } } @@ -44,4 +47,5 @@ func (s *Service) Reset() { s.traitRepository.Reset() s.aggregateRepository.Reset() s.usageRepository.Reset() + s.allocationRepository.Reset() } diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index f161ccf..541a38d 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -63,3 +63,12 @@ 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 +} 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 +} From 5e8d39a9a7937a4fb0e18f4d017d8dde156e0ac6 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:11:36 +0900 Subject: [PATCH 08/11] feat(placement): add allocation candidates endpoint --- .../api/placement/allocation_candidate.go | 43 ++++++++ .../api/placement/allocation_candidate_dto.go | 88 +++++++++++++++++ .../placement/allocation_candidate_test.go | 98 +++++++++++++++++++ internal/api/placement/router.go | 1 + .../app/placement/allocation_candidate.go | 90 +++++++++++++++++ internal/app/placement/types.go | 25 +++++ 6 files changed, 345 insertions(+) create mode 100644 internal/api/placement/allocation_candidate.go create mode 100644 internal/api/placement/allocation_candidate_dto.go create mode 100644 internal/api/placement/allocation_candidate_test.go create mode 100644 internal/app/placement/allocation_candidate.go 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/router.go b/internal/api/placement/router.go index b156a75..1a82f20 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -116,6 +116,7 @@ func (h Handler) Router() http.Handler { "/resource_providers/{resource_provider_uuid}/allocations", h.getAllocations, ) + router.Get("/allocation_candidates", h.listAllocationCandidates) return router } 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/types.go b/internal/app/placement/types.go index 541a38d..ea8aee5 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -72,3 +72,28 @@ 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 +} From 7f83a5a8798ee44804d67117d639cda97ab133cd Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:13:41 +0900 Subject: [PATCH 09/11] feat(placement): add resource class endpoints --- internal/api/placement/resource_class.go | 82 ++++++++++++++++ internal/api/placement/resource_class_dto.go | 46 +++++++++ internal/api/placement/resource_class_test.go | 97 +++++++++++++++++++ internal/api/placement/router.go | 6 ++ internal/api/router.go | 1 + internal/app/placement/repository.go | 8 ++ internal/app/placement/resource_class.go | 25 +++++ internal/app/placement/service.go | 5 + internal/app/placement/types.go | 4 + .../memory_resource_class_repository.go | 87 +++++++++++++++++ 10 files changed, 361 insertions(+) create mode 100644 internal/api/placement/resource_class.go create mode 100644 internal/api/placement/resource_class_dto.go create mode 100644 internal/api/placement/resource_class_test.go create mode 100644 internal/app/placement/resource_class.go create mode 100644 internal/store/placement/memory_resource_class_repository.go 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/router.go b/internal/api/placement/router.go index 1a82f20..0387434 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -36,6 +36,7 @@ func NewHandler(cfg config.Config) Handler { storeplacement.NewMemoryAggregateRepository(), storeplacement.NewMemoryUsageRepository(), storeplacement.NewMemoryAllocationRepository(), + storeplacement.NewMemoryResourceClassRepository(), idgen.Random(), ), ) @@ -117,6 +118,11 @@ func (h Handler) Router() http.Handler { 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) return router } diff --git a/internal/api/router.go b/internal/api/router.go index 3166944..3fc6342 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -90,6 +90,7 @@ func NewRouter(cfg config.Config) http.Handler { storeplacement.NewMemoryAggregateRepository(), storeplacement.NewMemoryUsageRepository(), storeplacement.NewMemoryAllocationRepository(), + storeplacement.NewMemoryResourceClassRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index f574f87..cb24ed5 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -45,3 +45,11 @@ type AllocationRepository interface { Delete(resourceProviderUUID string) Reset() } + +type ResourceClassRepository interface { + Create(resourceClass ResourceClass) ResourceClass + List() []ResourceClass + Get(name string) (ResourceClass, 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/service.go b/internal/app/placement/service.go index 92e112b..0e0c2dc 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -9,6 +9,7 @@ import ( var ( ErrResourceProviderNotFound = errors.New("resource provider not found") ErrInventoryNotFound = errors.New("inventory not found") + ErrResourceClassNotFound = errors.New("resource class not found") ) type Service struct { @@ -18,6 +19,7 @@ type Service struct { aggregateRepository AggregateRepository usageRepository UsageRepository allocationRepository AllocationRepository + resourceClassRepository ResourceClassRepository idGen idgen.Generator } @@ -28,6 +30,7 @@ func NewServiceWithRepositories( aggregateRepository AggregateRepository, usageRepository UsageRepository, allocationRepository AllocationRepository, + resourceClassRepository ResourceClassRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -37,6 +40,7 @@ func NewServiceWithRepositories( aggregateRepository: aggregateRepository, usageRepository: usageRepository, allocationRepository: allocationRepository, + resourceClassRepository: resourceClassRepository, idGen: idGen, } } @@ -48,4 +52,5 @@ func (s *Service) Reset() { s.aggregateRepository.Reset() s.usageRepository.Reset() s.allocationRepository.Reset() + s.resourceClassRepository.Reset() } diff --git a/internal/app/placement/types.go b/internal/app/placement/types.go index ea8aee5..6becf84 100644 --- a/internal/app/placement/types.go +++ b/internal/app/placement/types.go @@ -97,3 +97,7 @@ type ProviderSummaryResource struct { Capacity int Used int } + +type ResourceClass struct { + Name string +} 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{} +} From 87c700091d88bca0016e9d10ce6013086502dc33 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:16:35 +0900 Subject: [PATCH 10/11] feat(placement): add trait catalog endpoints --- internal/api/placement/router.go | 5 + internal/api/placement/trait_catalog.go | 54 +++++++++ internal/api/placement/trait_catalog_dto.go | 5 + internal/api/placement/trait_catalog_test.go | 103 ++++++++++++++++++ internal/api/router.go | 1 + internal/app/placement/repository.go | 8 ++ internal/app/placement/service.go | 5 + internal/app/placement/trait_catalog.go | 58 ++++++++++ .../memory_trait_catalog_repository.go | 81 ++++++++++++++ 9 files changed, 320 insertions(+) create mode 100644 internal/api/placement/trait_catalog.go create mode 100644 internal/api/placement/trait_catalog_dto.go create mode 100644 internal/api/placement/trait_catalog_test.go create mode 100644 internal/app/placement/trait_catalog.go create mode 100644 internal/store/placement/memory_trait_catalog_repository.go diff --git a/internal/api/placement/router.go b/internal/api/placement/router.go index 0387434..5a13325 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -37,6 +37,7 @@ func NewHandler(cfg config.Config) Handler { storeplacement.NewMemoryUsageRepository(), storeplacement.NewMemoryAllocationRepository(), storeplacement.NewMemoryResourceClassRepository(), + storeplacement.NewMemoryTraitCatalogRepository(), idgen.Random(), ), ) @@ -123,6 +124,10 @@ func (h Handler) Router() http.Handler { 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_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/router.go b/internal/api/router.go index 3fc6342..55aab55 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -91,6 +91,7 @@ func NewRouter(cfg config.Config) http.Handler { storeplacement.NewMemoryUsageRepository(), storeplacement.NewMemoryAllocationRepository(), storeplacement.NewMemoryResourceClassRepository(), + storeplacement.NewMemoryTraitCatalogRepository(), idgen.Random(), ) diff --git a/internal/app/placement/repository.go b/internal/app/placement/repository.go index cb24ed5..3f93521 100644 --- a/internal/app/placement/repository.go +++ b/internal/app/placement/repository.go @@ -53,3 +53,11 @@ type ResourceClassRepository interface { 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/service.go b/internal/app/placement/service.go index 0e0c2dc..5f8d9e8 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -10,6 +10,7 @@ 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 { @@ -20,6 +21,7 @@ type Service struct { usageRepository UsageRepository allocationRepository AllocationRepository resourceClassRepository ResourceClassRepository + traitCatalogRepository TraitCatalogRepository idGen idgen.Generator } @@ -31,6 +33,7 @@ func NewServiceWithRepositories( usageRepository UsageRepository, allocationRepository AllocationRepository, resourceClassRepository ResourceClassRepository, + traitCatalogRepository TraitCatalogRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -41,6 +44,7 @@ func NewServiceWithRepositories( usageRepository: usageRepository, allocationRepository: allocationRepository, resourceClassRepository: resourceClassRepository, + traitCatalogRepository: traitCatalogRepository, idGen: idGen, } } @@ -53,4 +57,5 @@ func (s *Service) Reset() { s.usageRepository.Reset() s.allocationRepository.Reset() s.resourceClassRepository.Reset() + s.traitCatalogRepository.Reset() } 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/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{} +} From 63658efcca5aa4829a9d7a65689d59dabd82b049 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 04:18:00 +0900 Subject: [PATCH 11/11] refactor(placement): group service repositories --- internal/api/placement/router.go | 18 +++++++++------- internal/api/router.go | 18 +++++++++------- internal/app/placement/service.go | 36 +++++++++++++++++-------------- 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/internal/api/placement/router.go b/internal/api/placement/router.go index 5a13325..a7bd8af 100644 --- a/internal/api/placement/router.go +++ b/internal/api/placement/router.go @@ -30,14 +30,16 @@ func NewHandler(cfg config.Config) Handler { return NewHandlerWithService( cfg, appplacement.NewServiceWithRepositories( - storeplacement.NewMemoryResourceProviderRepository(), - storeplacement.NewMemoryInventoryRepository(), - storeplacement.NewMemoryTraitRepository(), - storeplacement.NewMemoryAggregateRepository(), - storeplacement.NewMemoryUsageRepository(), - storeplacement.NewMemoryAllocationRepository(), - storeplacement.NewMemoryResourceClassRepository(), - storeplacement.NewMemoryTraitCatalogRepository(), + 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(), ), ) diff --git a/internal/api/router.go b/internal/api/router.go index 55aab55..bb8eb83 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -84,14 +84,16 @@ func NewRouter(cfg config.Config) http.Handler { idgen.Random(), ) placementService := appplacement.NewServiceWithRepositories( - storeplacement.NewMemoryResourceProviderRepository(), - storeplacement.NewMemoryInventoryRepository(), - storeplacement.NewMemoryTraitRepository(), - storeplacement.NewMemoryAggregateRepository(), - storeplacement.NewMemoryUsageRepository(), - storeplacement.NewMemoryAllocationRepository(), - storeplacement.NewMemoryResourceClassRepository(), - storeplacement.NewMemoryTraitCatalogRepository(), + 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(), ) diff --git a/internal/app/placement/service.go b/internal/app/placement/service.go index 5f8d9e8..fc33f89 100644 --- a/internal/app/placement/service.go +++ b/internal/app/placement/service.go @@ -25,26 +25,30 @@ type Service struct { idGen idgen.Generator } +type Repositories struct { + ResourceProviders ResourceProviderRepository + Inventories InventoryRepository + ProviderTraits TraitRepository + Aggregates AggregateRepository + Usages UsageRepository + Allocations AllocationRepository + ResourceClasses ResourceClassRepository + TraitCatalog TraitCatalogRepository +} + func NewServiceWithRepositories( - resourceProviderRepository ResourceProviderRepository, - inventoryRepository InventoryRepository, - traitRepository TraitRepository, - aggregateRepository AggregateRepository, - usageRepository UsageRepository, - allocationRepository AllocationRepository, - resourceClassRepository ResourceClassRepository, - traitCatalogRepository TraitCatalogRepository, + repositories Repositories, idGen idgen.Generator, ) *Service { return &Service{ - resourceProviderRepository: resourceProviderRepository, - inventoryRepository: inventoryRepository, - traitRepository: traitRepository, - aggregateRepository: aggregateRepository, - usageRepository: usageRepository, - allocationRepository: allocationRepository, - resourceClassRepository: resourceClassRepository, - traitCatalogRepository: traitCatalogRepository, + 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, } }