Skip to content

Commit 87c7000

Browse files
committed
feat(placement): add trait catalog endpoints
1 parent 7f83a5a commit 87c7000

9 files changed

Lines changed: 320 additions & 0 deletions

File tree

internal/api/placement/router.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ func NewHandler(cfg config.Config) Handler {
3737
storeplacement.NewMemoryUsageRepository(),
3838
storeplacement.NewMemoryAllocationRepository(),
3939
storeplacement.NewMemoryResourceClassRepository(),
40+
storeplacement.NewMemoryTraitCatalogRepository(),
4041
idgen.Random(),
4142
),
4243
)
@@ -123,6 +124,10 @@ func (h Handler) Router() http.Handler {
123124
router.Get("/resource_classes/{resource_class}", h.getResourceClass)
124125
router.Put("/resource_classes/{resource_class}", h.updateResourceClass)
125126
router.Delete("/resource_classes/{resource_class}", h.deleteResourceClass)
127+
router.Get("/traits", h.listTraits)
128+
router.Get("/traits/{trait_name}", h.getTrait)
129+
router.Put("/traits/{trait_name}", h.createTrait)
130+
router.Delete("/traits/{trait_name}", h.deleteTrait)
126131

127132
return router
128133
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package placement
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
7+
"github.com/JSYoo5B/SandStack/internal/api/respond"
8+
appplacement "github.com/JSYoo5B/SandStack/internal/app/placement"
9+
"github.com/go-chi/chi/v5"
10+
)
11+
12+
func (h Handler) listTraits(w http.ResponseWriter, r *http.Request) {
13+
respond.JSON(w, http.StatusOK, traitListResponse{
14+
Traits: h.service.ListTraits(r.URL.Query().Get("name")),
15+
})
16+
}
17+
18+
func (h Handler) getTrait(w http.ResponseWriter, r *http.Request) {
19+
_, err := h.service.GetTrait(chi.URLParam(r, "trait_name"))
20+
if errors.Is(err, appplacement.ErrTraitNotFound) {
21+
respond.Error(w, http.StatusNotFound, "trait not found")
22+
return
23+
}
24+
if err != nil {
25+
respond.Error(w, http.StatusInternalServerError, "trait lookup failed")
26+
return
27+
}
28+
29+
w.WriteHeader(http.StatusNoContent)
30+
}
31+
32+
func (h Handler) createTrait(w http.ResponseWriter, r *http.Request) {
33+
created := h.service.CreateTrait(chi.URLParam(r, "trait_name"))
34+
if !created {
35+
w.WriteHeader(http.StatusNoContent)
36+
return
37+
}
38+
39+
w.WriteHeader(http.StatusCreated)
40+
}
41+
42+
func (h Handler) deleteTrait(w http.ResponseWriter, r *http.Request) {
43+
err := h.service.DeleteTrait(chi.URLParam(r, "trait_name"))
44+
if errors.Is(err, appplacement.ErrTraitNotFound) {
45+
respond.Error(w, http.StatusNotFound, "trait not found")
46+
return
47+
}
48+
if err != nil {
49+
respond.Error(w, http.StatusInternalServerError, "trait delete failed")
50+
return
51+
}
52+
53+
w.WriteHeader(http.StatusNoContent)
54+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package placement
2+
3+
type traitListResponse struct {
4+
Traits []string `json:"traits"`
5+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package placement_test
2+
3+
import (
4+
"context"
5+
"net/http/httptest"
6+
"testing"
7+
8+
"github.com/JSYoo5B/SandStack/internal/api/placement"
9+
"github.com/JSYoo5B/SandStack/internal/testhelper"
10+
"github.com/gophercloud/gophercloud/v2/openstack/placement/v1/traits"
11+
"github.com/stretchr/testify/suite"
12+
)
13+
14+
type TraitCatalogSuite struct {
15+
suite.Suite
16+
server *httptest.Server
17+
}
18+
19+
func TestTraitCatalogSuite(t *testing.T) {
20+
suite.Run(t, new(TraitCatalogSuite))
21+
}
22+
23+
func (s *TraitCatalogSuite) SetupTest() {
24+
s.server = httptest.NewServer(
25+
placement.NewRouter(testhelper.DefaultConfig()),
26+
)
27+
}
28+
29+
func (s *TraitCatalogSuite) TearDownTest() {
30+
s.server.Close()
31+
}
32+
33+
func (s *TraitCatalogSuite) TestCreateGetAndListTrait() {
34+
client := testhelper.ServiceClient(s.server.URL)
35+
36+
err := traits.Create(
37+
context.Background(),
38+
client,
39+
"CUSTOM_GPU",
40+
).ExtractErr()
41+
s.Require().NoError(err)
42+
43+
err = traits.Get(context.Background(), client, "CUSTOM_GPU").ExtractErr()
44+
s.Require().NoError(err)
45+
46+
pages, err := traits.List(client, nil).AllPages(context.Background())
47+
s.Require().NoError(err)
48+
49+
found, err := traits.ExtractTraits(pages)
50+
s.Require().NoError(err)
51+
52+
s.Assert().Equal([]string{"CUSTOM_GPU"}, found)
53+
}
54+
55+
func (s *TraitCatalogSuite) TestListTraitWithNameFilter() {
56+
client := testhelper.ServiceClient(s.server.URL)
57+
58+
err := traits.Create(
59+
context.Background(),
60+
client,
61+
"CUSTOM_GPU",
62+
).ExtractErr()
63+
s.Require().NoError(err)
64+
err = traits.Create(
65+
context.Background(),
66+
client,
67+
"CUSTOM_FPGA",
68+
).ExtractErr()
69+
s.Require().NoError(err)
70+
71+
pages, err := traits.List(
72+
client,
73+
traits.ListOpts{Name: "startswith:CUSTOM_G"},
74+
).AllPages(context.Background())
75+
s.Require().NoError(err)
76+
77+
found, err := traits.ExtractTraits(pages)
78+
s.Require().NoError(err)
79+
80+
s.Assert().Equal([]string{"CUSTOM_GPU"}, found)
81+
}
82+
83+
func (s *TraitCatalogSuite) TestDeleteTrait() {
84+
client := testhelper.ServiceClient(s.server.URL)
85+
86+
err := traits.Create(
87+
context.Background(),
88+
client,
89+
"CUSTOM_GPU",
90+
).ExtractErr()
91+
s.Require().NoError(err)
92+
93+
err = traits.Delete(context.Background(), client, "CUSTOM_GPU").ExtractErr()
94+
s.Require().NoError(err)
95+
96+
pages, err := traits.List(client, nil).AllPages(context.Background())
97+
s.Require().NoError(err)
98+
99+
found, err := traits.ExtractTraits(pages)
100+
s.Require().NoError(err)
101+
102+
s.Assert().Empty(found)
103+
}

internal/api/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ func NewRouter(cfg config.Config) http.Handler {
9191
storeplacement.NewMemoryUsageRepository(),
9292
storeplacement.NewMemoryAllocationRepository(),
9393
storeplacement.NewMemoryResourceClassRepository(),
94+
storeplacement.NewMemoryTraitCatalogRepository(),
9495
idgen.Random(),
9596
)
9697

internal/app/placement/repository.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,11 @@ type ResourceClassRepository interface {
5353
Delete(name string) error
5454
Reset()
5555
}
56+
57+
type TraitCatalogRepository interface {
58+
Create(name string)
59+
List() []string
60+
Get(name string) (string, error)
61+
Delete(name string) error
62+
Reset()
63+
}

internal/app/placement/service.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ var (
1010
ErrResourceProviderNotFound = errors.New("resource provider not found")
1111
ErrInventoryNotFound = errors.New("inventory not found")
1212
ErrResourceClassNotFound = errors.New("resource class not found")
13+
ErrTraitNotFound = errors.New("trait not found")
1314
)
1415

1516
type Service struct {
@@ -20,6 +21,7 @@ type Service struct {
2021
usageRepository UsageRepository
2122
allocationRepository AllocationRepository
2223
resourceClassRepository ResourceClassRepository
24+
traitCatalogRepository TraitCatalogRepository
2325
idGen idgen.Generator
2426
}
2527

@@ -31,6 +33,7 @@ func NewServiceWithRepositories(
3133
usageRepository UsageRepository,
3234
allocationRepository AllocationRepository,
3335
resourceClassRepository ResourceClassRepository,
36+
traitCatalogRepository TraitCatalogRepository,
3437
idGen idgen.Generator,
3538
) *Service {
3639
return &Service{
@@ -41,6 +44,7 @@ func NewServiceWithRepositories(
4144
usageRepository: usageRepository,
4245
allocationRepository: allocationRepository,
4346
resourceClassRepository: resourceClassRepository,
47+
traitCatalogRepository: traitCatalogRepository,
4448
idGen: idGen,
4549
}
4650
}
@@ -53,4 +57,5 @@ func (s *Service) Reset() {
5357
s.usageRepository.Reset()
5458
s.allocationRepository.Reset()
5559
s.resourceClassRepository.Reset()
60+
s.traitCatalogRepository.Reset()
5661
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package placement
2+
3+
import "strings"
4+
5+
func (s *Service) CreateTrait(name string) (created bool) {
6+
if _, err := s.traitCatalogRepository.Get(name); err == nil {
7+
return false
8+
}
9+
10+
s.traitCatalogRepository.Create(name)
11+
return true
12+
}
13+
14+
func (s *Service) ListTraits(nameFilter string) []string {
15+
traits := s.traitCatalogRepository.List()
16+
if nameFilter == "" {
17+
return traits
18+
}
19+
20+
if after, ok := strings.CutPrefix(nameFilter, "startswith:"); ok {
21+
return filterTraits(traits, func(trait string) bool {
22+
return strings.HasPrefix(trait, after)
23+
})
24+
}
25+
26+
if after, ok := strings.CutPrefix(nameFilter, "in:"); ok {
27+
allowed := map[string]bool{}
28+
for _, name := range strings.Split(after, ",") {
29+
allowed[name] = true
30+
}
31+
return filterTraits(traits, func(trait string) bool {
32+
return allowed[trait]
33+
})
34+
}
35+
36+
return filterTraits(traits, func(trait string) bool {
37+
return trait == nameFilter
38+
})
39+
}
40+
41+
func (s *Service) GetTrait(name string) (string, error) {
42+
return s.traitCatalogRepository.Get(name)
43+
}
44+
45+
func (s *Service) DeleteTrait(name string) error {
46+
return s.traitCatalogRepository.Delete(name)
47+
}
48+
49+
func filterTraits(traits []string, keep func(string) bool) []string {
50+
filtered := []string{}
51+
for _, trait := range traits {
52+
if keep(trait) {
53+
filtered = append(filtered, trait)
54+
}
55+
}
56+
57+
return filtered
58+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package placement
2+
3+
import (
4+
"sync"
5+
6+
appplacement "github.com/JSYoo5B/SandStack/internal/app/placement"
7+
)
8+
9+
type MemoryTraitCatalogRepository struct {
10+
mu sync.RWMutex
11+
names []string
12+
traits map[string]string
13+
}
14+
15+
func NewMemoryTraitCatalogRepository() *MemoryTraitCatalogRepository {
16+
return &MemoryTraitCatalogRepository{
17+
names: []string{},
18+
traits: map[string]string{},
19+
}
20+
}
21+
22+
func (r *MemoryTraitCatalogRepository) Create(name string) {
23+
r.mu.Lock()
24+
defer r.mu.Unlock()
25+
26+
if _, ok := r.traits[name]; !ok {
27+
r.names = append(r.names, name)
28+
}
29+
r.traits[name] = name
30+
}
31+
32+
func (r *MemoryTraitCatalogRepository) List() []string {
33+
r.mu.RLock()
34+
defer r.mu.RUnlock()
35+
36+
traits := make([]string, 0, len(r.names))
37+
for _, name := range r.names {
38+
traits = append(traits, r.traits[name])
39+
}
40+
41+
return traits
42+
}
43+
44+
func (r *MemoryTraitCatalogRepository) Get(name string) (string, error) {
45+
r.mu.RLock()
46+
defer r.mu.RUnlock()
47+
48+
trait, ok := r.traits[name]
49+
if !ok {
50+
return "", appplacement.ErrTraitNotFound
51+
}
52+
53+
return trait, nil
54+
}
55+
56+
func (r *MemoryTraitCatalogRepository) Delete(name string) error {
57+
r.mu.Lock()
58+
defer r.mu.Unlock()
59+
60+
if _, ok := r.traits[name]; !ok {
61+
return appplacement.ErrTraitNotFound
62+
}
63+
64+
delete(r.traits, name)
65+
for index, currentName := range r.names {
66+
if currentName == name {
67+
r.names = append(r.names[:index], r.names[index+1:]...)
68+
break
69+
}
70+
}
71+
72+
return nil
73+
}
74+
75+
func (r *MemoryTraitCatalogRepository) Reset() {
76+
r.mu.Lock()
77+
defer r.mu.Unlock()
78+
79+
r.names = []string{}
80+
r.traits = map[string]string{}
81+
}

0 commit comments

Comments
 (0)