From e1eaf7d39bb0654eaae7355121df2af782f5187a Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:01:42 +0900 Subject: [PATCH 01/21] feat(image): add empty image list endpoint --- internal/api/image/image.go | 13 ++++++++++ internal/api/image/image_dto.go | 11 ++++++++ internal/api/image/image_test.go | 43 ++++++++++++++++++++++++++++++++ internal/api/image/router.go | 1 + 4 files changed, 68 insertions(+) create mode 100644 internal/api/image/image.go create mode 100644 internal/api/image/image_dto.go create mode 100644 internal/api/image/image_test.go diff --git a/internal/api/image/image.go b/internal/api/image/image.go new file mode 100644 index 0000000..243cf77 --- /dev/null +++ b/internal/api/image/image.go @@ -0,0 +1,13 @@ +package image + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listImages(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, imageListResponse{ + Images: []imageDocument{}, + }) +} diff --git a/internal/api/image/image_dto.go b/internal/api/image/image_dto.go new file mode 100644 index 0000000..52fd429 --- /dev/null +++ b/internal/api/image/image_dto.go @@ -0,0 +1,11 @@ +package image + +type imageListResponse struct { + Images []imageDocument `json:"images"` +} + +type imageDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` +} diff --git a/internal/api/image/image_test.go b/internal/api/image/image_test.go new file mode 100644 index 0000000..ff668d9 --- /dev/null +++ b/internal/api/image/image_test.go @@ -0,0 +1,43 @@ +package image_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/image" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/stretchr/testify/suite" +) + +type ImageSuite struct { + suite.Suite + server *httptest.Server +} + +func TestImageSuite(t *testing.T) { + suite.Run(t, new(ImageSuite)) +} + +func (s *ImageSuite) SetupTest() { + s.server = httptest.NewServer( + image.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ImageSuite) TearDownTest() { + s.server.Close() +} + +func (s *ImageSuite) TestListImages() { + pages, err := images.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := images.ExtractImages(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} diff --git a/internal/api/image/router.go b/internal/api/image/router.go index 9aca65a..8aee4b4 100644 --- a/internal/api/image/router.go +++ b/internal/api/image/router.go @@ -22,6 +22,7 @@ func NewHandler(cfg config.Config) Handler { func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.versions) + router.Get("/images", h.listImages) return router } From 412167d201978e488b0059a194746ba090038312 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:03:22 +0900 Subject: [PATCH 02/21] feat(image): add in-memory create and list --- internal/api/image/image.go | 14 ++++++- internal/api/image/image_dto.go | 67 ++++++++++++++++++++++++++++++-- internal/api/image/image_test.go | 31 ++++++++++++++- internal/api/image/router.go | 10 ++++- internal/app/image/service.go | 59 ++++++++++++++++++++++++++++ internal/app/image/types.go | 25 ++++++++++++ 6 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 internal/app/image/service.go create mode 100644 internal/app/image/types.go diff --git a/internal/api/image/image.go b/internal/api/image/image.go index 243cf77..580f90d 100644 --- a/internal/api/image/image.go +++ b/internal/api/image/image.go @@ -1,6 +1,7 @@ package image import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -8,6 +9,17 @@ import ( func (h Handler) listImages(w http.ResponseWriter, r *http.Request) { respond.JSON(w, http.StatusOK, imageListResponse{ - Images: []imageDocument{}, + Images: toImageDocuments(h.service.List()), }) } + +func (h Handler) createImage(w http.ResponseWriter, r *http.Request) { + var request createImageRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + image := h.service.Create(request.createImage()) + respond.JSON(w, http.StatusCreated, toImageDocument(image)) +} diff --git a/internal/api/image/image_dto.go b/internal/api/image/image_dto.go index 52fd429..72722f3 100644 --- a/internal/api/image/image_dto.go +++ b/internal/api/image/image_dto.go @@ -1,11 +1,72 @@ package image +import appimage "github.com/JSYoo5B/SandStack/internal/app/image" + +type createImageRequest struct { + Name string `json:"name"` + ContainerFormat string `json:"container_format"` + DiskFormat string `json:"disk_format"` + MinDisk int `json:"min_disk"` + MinRAM int `json:"min_ram"` + Tags []string `json:"tags"` +} + +func (r createImageRequest) createImage() appimage.CreateImage { + return appimage.CreateImage{ + Name: r.Name, + ContainerFormat: r.ContainerFormat, + DiskFormat: r.DiskFormat, + MinDisk: r.MinDisk, + MinRAM: r.MinRAM, + Tags: r.Tags, + } +} + type imageListResponse struct { Images []imageDocument `json:"images"` } type imageDocument struct { - ID string `json:"id"` - Name string `json:"name"` - Status string `json:"status"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ContainerFormat string `json:"container_format"` + DiskFormat string `json:"disk_format"` + MinDisk int `json:"min_disk"` + MinRAM int `json:"min_ram"` + Protected bool `json:"protected"` + Visibility string `json:"visibility"` + Tags []string `json:"tags"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + File string `json:"file"` + Schema string `json:"schema"` +} + +func toImageDocuments(images []appimage.Image) []imageDocument { + documents := make([]imageDocument, 0, len(images)) + for _, image := range images { + documents = append(documents, toImageDocument(image)) + } + + return documents +} + +func toImageDocument(image appimage.Image) imageDocument { + return imageDocument{ + ID: image.ID, + Name: image.Name, + Status: image.Status, + ContainerFormat: image.ContainerFormat, + DiskFormat: image.DiskFormat, + MinDisk: image.MinDisk, + MinRAM: image.MinRAM, + Protected: image.Protected, + Visibility: image.Visibility, + Tags: image.Tags, + CreatedAt: image.CreatedAt, + UpdatedAt: image.UpdatedAt, + File: "/v2/images/" + image.ID + "/file", + Schema: "/v2/schemas/image", + } } diff --git a/internal/api/image/image_test.go b/internal/api/image/image_test.go index ff668d9..a3dd867 100644 --- a/internal/api/image/image_test.go +++ b/internal/api/image/image_test.go @@ -30,6 +30,35 @@ func (s *ImageSuite) TearDownTest() { } func (s *ImageSuite) TestListImages() { + list := s.listImages() + + s.Assert().Empty(list) +} + +func (s *ImageSuite) TestCreateImageThenListImages() { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + images.CreateOpts{ + Name: "ubuntu", + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + list := s.listImages() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("ubuntu", created.Name) + s.Assert().Equal(images.ImageStatus("queued"), created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("ubuntu", list[0].Name) +} + +func (s *ImageSuite) listImages() []images.Image { pages, err := images.List( testhelper.ServiceClient(s.server.URL), nil, @@ -39,5 +68,5 @@ func (s *ImageSuite) TestListImages() { list, err := images.ExtractImages(pages) s.Require().NoError(err) - s.Assert().Empty(list) + return list } diff --git a/internal/api/image/router.go b/internal/api/image/router.go index 8aee4b4..b18a88a 100644 --- a/internal/api/image/router.go +++ b/internal/api/image/router.go @@ -3,12 +3,14 @@ package image import ( "net/http" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/go-chi/chi/v5" ) type Handler struct { - config config.Config + config config.Config + service *appimage.Service } func NewRouter(cfg config.Config) http.Handler { @@ -16,13 +18,17 @@ func NewRouter(cfg config.Config) http.Handler { } func NewHandler(cfg config.Config) Handler { - return Handler{config: cfg} + return Handler{ + config: cfg, + service: appimage.NewService(), + } } func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.versions) router.Get("/images", h.listImages) + router.Post("/images", h.createImage) return router } diff --git a/internal/app/image/service.go b/internal/app/image/service.go new file mode 100644 index 0000000..0bfa31b --- /dev/null +++ b/internal/app/image/service.go @@ -0,0 +1,59 @@ +package image + +import ( + "sync" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +type Service struct { + mu sync.RWMutex + ids []string + images map[string]Image +} + +func NewService() *Service { + return &Service{ + ids: []string{}, + images: map[string]Image{}, + } +} + +func (s *Service) Create(input CreateImage) Image { + now := time.Now().UTC() + image := Image{ + ID: "img-" + idgen.RandomHex(16), + Name: input.Name, + Status: "queued", + ContainerFormat: input.ContainerFormat, + DiskFormat: input.DiskFormat, + MinDisk: input.MinDisk, + MinRAM: input.MinRAM, + Protected: false, + Visibility: "private", + Tags: input.Tags, + CreatedAt: now.Format(time.RFC3339), + UpdatedAt: now.Format(time.RFC3339), + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, image.ID) + s.images[image.ID] = image + + return image +} + +func (s *Service) List() []Image { + s.mu.RLock() + defer s.mu.RUnlock() + + images := make([]Image, 0, len(s.ids)) + for _, id := range s.ids { + images = append(images, s.images[id]) + } + + return images +} diff --git a/internal/app/image/types.go b/internal/app/image/types.go new file mode 100644 index 0000000..9eca7d8 --- /dev/null +++ b/internal/app/image/types.go @@ -0,0 +1,25 @@ +package image + +type CreateImage struct { + Name string + ContainerFormat string + DiskFormat string + MinDisk int + MinRAM int + Tags []string +} + +type Image struct { + ID string + Name string + Status string + ContainerFormat string + DiskFormat string + MinDisk int + MinRAM int + Protected bool + Visibility string + Tags []string + CreatedAt string + UpdatedAt string +} From 79272d9513c8aca5fc5ac66276ceb9e4879ae83f Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:04:37 +0900 Subject: [PATCH 03/21] feat(image): add image get and delete endpoints --- internal/api/image/image.go | 31 +++++++++++++++++ internal/api/image/image_test.go | 58 ++++++++++++++++++++++++++------ internal/api/image/router.go | 2 ++ internal/app/image/service.go | 34 +++++++++++++++++++ 4 files changed, 114 insertions(+), 11 deletions(-) diff --git a/internal/api/image/image.go b/internal/api/image/image.go index 580f90d..364f23b 100644 --- a/internal/api/image/image.go +++ b/internal/api/image/image.go @@ -2,9 +2,12 @@ package image import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/go-chi/chi/v5" ) func (h Handler) listImages(w http.ResponseWriter, r *http.Request) { @@ -23,3 +26,31 @@ func (h Handler) createImage(w http.ResponseWriter, r *http.Request) { image := h.service.Create(request.createImage()) respond.JSON(w, http.StatusCreated, toImageDocument(image)) } + +func (h Handler) getImage(w http.ResponseWriter, r *http.Request) { + image, err := h.service.Get(chi.URLParam(r, "image_id")) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image lookup failed") + return + } + + respond.JSON(w, http.StatusOK, toImageDocument(image)) +} + +func (h Handler) deleteImage(w http.ResponseWriter, r *http.Request) { + err := h.service.Delete(chi.URLParam(r, "image_id")) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/image/image_test.go b/internal/api/image/image_test.go index a3dd867..b6b5ed5 100644 --- a/internal/api/image/image_test.go +++ b/internal/api/image/image_test.go @@ -36,17 +36,7 @@ func (s *ImageSuite) TestListImages() { } func (s *ImageSuite) TestCreateImageThenListImages() { - created, err := images.Create( - s.T().Context(), - testhelper.ServiceClient(s.server.URL), - images.CreateOpts{ - Name: "ubuntu", - ContainerFormat: "bare", - DiskFormat: "qcow2", - }, - ).Extract() - s.Require().NoError(err) - s.Require().NotNil(created) + created := s.createImage("ubuntu") list := s.listImages() @@ -58,6 +48,36 @@ func (s *ImageSuite) TestCreateImageThenListImages() { s.Assert().Equal("ubuntu", list[0].Name) } +func (s *ImageSuite) TestGetImage() { + created := s.createImage("ubuntu") + + found, err := images.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("ubuntu", found.Name) +} + +func (s *ImageSuite) TestDeleteImage() { + created := s.createImage("ubuntu") + + err := images.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listImages() + + s.Assert().Empty(list) +} + func (s *ImageSuite) listImages() []images.Image { pages, err := images.List( testhelper.ServiceClient(s.server.URL), @@ -70,3 +90,19 @@ func (s *ImageSuite) listImages() []images.Image { return list } + +func (s *ImageSuite) createImage(name string) *images.Image { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + images.CreateOpts{ + Name: name, + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/image/router.go b/internal/api/image/router.go index b18a88a..e944a44 100644 --- a/internal/api/image/router.go +++ b/internal/api/image/router.go @@ -29,6 +29,8 @@ func (h Handler) Router() http.Handler { router.Get("/", h.versions) router.Get("/images", h.listImages) router.Post("/images", h.createImage) + router.Get("/images/{image_id}", h.getImage) + router.Delete("/images/{image_id}", h.deleteImage) return router } diff --git a/internal/app/image/service.go b/internal/app/image/service.go index 0bfa31b..1b8c375 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -1,12 +1,15 @@ package image import ( + "errors" "sync" "time" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) +var ErrImageNotFound = errors.New("image not found") + type Service struct { mu sync.RWMutex ids []string @@ -57,3 +60,34 @@ func (s *Service) List() []Image { return images } + +func (s *Service) Get(id string) (Image, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + image, ok := s.images[id] + if !ok { + return Image{}, ErrImageNotFound + } + + return image, nil +} + +func (s *Service) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.images[id]; !ok { + return ErrImageNotFound + } + + delete(s.images, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} From 28a2a2529f6a173affa148fecc46bebd4bc3cb52 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:05:28 +0900 Subject: [PATCH 04/21] feat(network): add empty network list endpoint --- internal/api/network/network.go | 13 +++++++++ internal/api/network/network_dto.go | 17 +++++++++++ internal/api/network/network_test.go | 43 ++++++++++++++++++++++++++++ internal/api/network/router.go | 1 + 4 files changed, 74 insertions(+) create mode 100644 internal/api/network/network.go create mode 100644 internal/api/network/network_dto.go create mode 100644 internal/api/network/network_test.go diff --git a/internal/api/network/network.go b/internal/api/network/network.go new file mode 100644 index 0000000..6434cf7 --- /dev/null +++ b/internal/api/network/network.go @@ -0,0 +1,13 @@ +package network + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listNetworks(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, networkListResponse{ + Networks: []networkDocument{}, + }) +} diff --git a/internal/api/network/network_dto.go b/internal/api/network/network_dto.go new file mode 100644 index 0000000..c0d0eba --- /dev/null +++ b/internal/api/network/network_dto.go @@ -0,0 +1,17 @@ +package network + +type networkListResponse struct { + Networks []networkDocument `json:"networks"` +} + +type networkDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp bool `json:"admin_state_up"` + Status string `json:"status"` + Subnets []string `json:"subnets"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + Shared bool `json:"shared"` +} diff --git a/internal/api/network/network_test.go b/internal/api/network/network_test.go new file mode 100644 index 0000000..51110f4 --- /dev/null +++ b/internal/api/network/network_test.go @@ -0,0 +1,43 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "github.com/stretchr/testify/suite" +) + +type NetworkSuite struct { + suite.Suite + server *httptest.Server +} + +func TestNetworkSuite(t *testing.T) { + suite.Run(t, new(NetworkSuite)) +} + +func (s *NetworkSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *NetworkSuite) TearDownTest() { + s.server.Close() +} + +func (s *NetworkSuite) TestListNetworks() { + pages, err := networks.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := networks.ExtractNetworks(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index b82902a..a88f845 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -22,6 +22,7 @@ func NewHandler(cfg config.Config) Handler { func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.versions) + router.Get("/networks", h.listNetworks) return router } From 28e39adef0e0f47d179843f45f51554db0ba6924 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:06:26 +0900 Subject: [PATCH 05/21] feat(network): add in-memory create and list --- internal/api/network/network.go | 16 +++++++- internal/api/network/network_dto.go | 55 ++++++++++++++++++++++++++ internal/api/network/network_test.go | 30 +++++++++++++- internal/api/network/router.go | 10 ++++- internal/app/network/service.go | 59 ++++++++++++++++++++++++++++ internal/app/network/types.go | 21 ++++++++++ 6 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 internal/app/network/service.go create mode 100644 internal/app/network/types.go diff --git a/internal/api/network/network.go b/internal/api/network/network.go index 6434cf7..5edda64 100644 --- a/internal/api/network/network.go +++ b/internal/api/network/network.go @@ -1,6 +1,7 @@ package network import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -8,6 +9,19 @@ import ( func (h Handler) listNetworks(w http.ResponseWriter, r *http.Request) { respond.JSON(w, http.StatusOK, networkListResponse{ - Networks: []networkDocument{}, + Networks: toNetworkDocuments(h.service.List()), + }) +} + +func (h Handler) createNetwork(w http.ResponseWriter, r *http.Request) { + var request createNetworkRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + network := h.service.Create(request.createNetwork()) + respond.JSON(w, http.StatusCreated, networkResponse{ + Network: toNetworkDocument(network), }) } diff --git a/internal/api/network/network_dto.go b/internal/api/network/network_dto.go index c0d0eba..229b9ae 100644 --- a/internal/api/network/network_dto.go +++ b/internal/api/network/network_dto.go @@ -1,9 +1,41 @@ package network +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type createNetworkRequest struct { + Network struct { + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp *bool `json:"admin_state_up"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` + Shared bool `json:"shared"` + } `json:"network"` +} + +func (r createNetworkRequest) createNetwork() appnetwork.CreateNetwork { + projectID := r.Network.ProjectID + if projectID == "" { + projectID = r.Network.TenantID + } + + return appnetwork.CreateNetwork{ + Name: r.Network.Name, + Description: r.Network.Description, + AdminStateUp: r.Network.AdminStateUp, + ProjectID: projectID, + Shared: r.Network.Shared, + } +} + type networkListResponse struct { Networks []networkDocument `json:"networks"` } +type networkResponse struct { + Network networkDocument `json:"network"` +} + type networkDocument struct { ID string `json:"id"` Name string `json:"name"` @@ -15,3 +47,26 @@ type networkDocument struct { ProjectID string `json:"project_id"` Shared bool `json:"shared"` } + +func toNetworkDocuments(networks []appnetwork.Network) []networkDocument { + documents := make([]networkDocument, 0, len(networks)) + for _, network := range networks { + documents = append(documents, toNetworkDocument(network)) + } + + return documents +} + +func toNetworkDocument(network appnetwork.Network) networkDocument { + return networkDocument{ + ID: network.ID, + Name: network.Name, + Description: network.Description, + AdminStateUp: network.AdminStateUp, + Status: network.Status, + Subnets: network.Subnets, + TenantID: network.TenantID, + ProjectID: network.ProjectID, + Shared: network.Shared, + } +} diff --git a/internal/api/network/network_test.go b/internal/api/network/network_test.go index 51110f4..e739caa 100644 --- a/internal/api/network/network_test.go +++ b/internal/api/network/network_test.go @@ -30,6 +30,34 @@ func (s *NetworkSuite) TearDownTest() { } func (s *NetworkSuite) TestListNetworks() { + list := s.listNetworks() + + s.Assert().Empty(list) +} + +func (s *NetworkSuite) TestCreateNetworkThenListNetworks() { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + networks.CreateOpts{ + Name: "private", + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + list := s.listNetworks() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("private", created.Name) + s.Assert().Equal("ACTIVE", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("private", list[0].Name) +} + +func (s *NetworkSuite) listNetworks() []networks.Network { pages, err := networks.List( testhelper.ServiceClient(s.server.URL), nil, @@ -39,5 +67,5 @@ func (s *NetworkSuite) TestListNetworks() { list, err := networks.ExtractNetworks(pages) s.Require().NoError(err) - s.Assert().Empty(list) + return list } diff --git a/internal/api/network/router.go b/internal/api/network/router.go index a88f845..7243751 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -3,12 +3,14 @@ package network import ( "net/http" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/go-chi/chi/v5" ) type Handler struct { - config config.Config + config config.Config + service *appnetwork.Service } func NewRouter(cfg config.Config) http.Handler { @@ -16,13 +18,17 @@ func NewRouter(cfg config.Config) http.Handler { } func NewHandler(cfg config.Config) Handler { - return Handler{config: cfg} + return Handler{ + config: cfg, + service: appnetwork.NewService(), + } } func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/", h.versions) router.Get("/networks", h.listNetworks) + router.Post("/networks", h.createNetwork) return router } diff --git a/internal/app/network/service.go b/internal/app/network/service.go new file mode 100644 index 0000000..7de74de --- /dev/null +++ b/internal/app/network/service.go @@ -0,0 +1,59 @@ +package network + +import ( + "sync" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +type Service struct { + mu sync.RWMutex + ids []string + networks map[string]Network +} + +func NewService() *Service { + return &Service{ + ids: []string{}, + networks: map[string]Network{}, + } +} + +func (s *Service) Create(input CreateNetwork) Network { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + network := Network{ + ID: "net-" + idgen.RandomHex(16), + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "ACTIVE", + Subnets: []string{}, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + Shared: input.Shared, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, network.ID) + s.networks[network.ID] = network + + return network +} + +func (s *Service) List() []Network { + s.mu.RLock() + defer s.mu.RUnlock() + + networks := make([]Network, 0, len(s.ids)) + for _, id := range s.ids { + networks = append(networks, s.networks[id]) + } + + return networks +} diff --git a/internal/app/network/types.go b/internal/app/network/types.go new file mode 100644 index 0000000..3b079c0 --- /dev/null +++ b/internal/app/network/types.go @@ -0,0 +1,21 @@ +package network + +type CreateNetwork struct { + Name string + Description string + AdminStateUp *bool + ProjectID string + Shared bool +} + +type Network struct { + ID string + Name string + Description string + AdminStateUp bool + Status string + Subnets []string + TenantID string + ProjectID string + Shared bool +} From 063dd75ecf553a2b06f7c5857ee76984f7b412c5 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:07:12 +0900 Subject: [PATCH 06/21] feat(network): add network get and delete endpoints --- internal/api/network/network.go | 33 ++++++++++++++++ internal/api/network/network_test.go | 56 +++++++++++++++++++++++----- internal/api/network/router.go | 2 + internal/app/network/service.go | 34 +++++++++++++++++ 4 files changed, 115 insertions(+), 10 deletions(-) diff --git a/internal/api/network/network.go b/internal/api/network/network.go index 5edda64..0166a3e 100644 --- a/internal/api/network/network.go +++ b/internal/api/network/network.go @@ -2,9 +2,12 @@ package network import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" ) func (h Handler) listNetworks(w http.ResponseWriter, r *http.Request) { @@ -25,3 +28,33 @@ func (h Handler) createNetwork(w http.ResponseWriter, r *http.Request) { Network: toNetworkDocument(network), }) } + +func (h Handler) getNetwork(w http.ResponseWriter, r *http.Request) { + network, err := h.service.Get(chi.URLParam(r, "network_id")) + if errors.Is(err, appnetwork.ErrNetworkNotFound) { + respond.Error(w, http.StatusNotFound, "network not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "network lookup failed") + return + } + + respond.JSON(w, http.StatusOK, networkResponse{ + Network: toNetworkDocument(network), + }) +} + +func (h Handler) deleteNetwork(w http.ResponseWriter, r *http.Request) { + err := h.service.Delete(chi.URLParam(r, "network_id")) + if errors.Is(err, appnetwork.ErrNetworkNotFound) { + respond.Error(w, http.StatusNotFound, "network not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "network delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/network_test.go b/internal/api/network/network_test.go index e739caa..9e348fc 100644 --- a/internal/api/network/network_test.go +++ b/internal/api/network/network_test.go @@ -36,16 +36,7 @@ func (s *NetworkSuite) TestListNetworks() { } func (s *NetworkSuite) TestCreateNetworkThenListNetworks() { - created, err := networks.Create( - s.T().Context(), - testhelper.ServiceClient(s.server.URL), - networks.CreateOpts{ - Name: "private", - ProjectID: "demo", - }, - ).Extract() - s.Require().NoError(err) - s.Require().NotNil(created) + created := s.createNetwork("private") list := s.listNetworks() @@ -57,6 +48,36 @@ func (s *NetworkSuite) TestCreateNetworkThenListNetworks() { s.Assert().Equal("private", list[0].Name) } +func (s *NetworkSuite) TestGetNetwork() { + created := s.createNetwork("private") + + found, err := networks.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("private", found.Name) +} + +func (s *NetworkSuite) TestDeleteNetwork() { + created := s.createNetwork("private") + + err := networks.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listNetworks() + + s.Assert().Empty(list) +} + func (s *NetworkSuite) listNetworks() []networks.Network { pages, err := networks.List( testhelper.ServiceClient(s.server.URL), @@ -69,3 +90,18 @@ func (s *NetworkSuite) listNetworks() []networks.Network { return list } + +func (s *NetworkSuite) createNetwork(name string) *networks.Network { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + networks.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 7243751..02cb982 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -29,6 +29,8 @@ func (h Handler) Router() http.Handler { router.Get("/", h.versions) router.Get("/networks", h.listNetworks) router.Post("/networks", h.createNetwork) + router.Get("/networks/{network_id}", h.getNetwork) + router.Delete("/networks/{network_id}", h.deleteNetwork) return router } diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 7de74de..98cc41e 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -1,11 +1,14 @@ package network import ( + "errors" "sync" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) +var ErrNetworkNotFound = errors.New("network not found") + type Service struct { mu sync.RWMutex ids []string @@ -57,3 +60,34 @@ func (s *Service) List() []Network { return networks } + +func (s *Service) Get(id string) (Network, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + network, ok := s.networks[id] + if !ok { + return Network{}, ErrNetworkNotFound + } + + return network, nil +} + +func (s *Service) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.networks[id]; !ok { + return ErrNetworkNotFound + } + + delete(s.networks, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} From 5919a6bf17ebbe229fd76bb91e57f5b47d103b43 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:08:51 +0900 Subject: [PATCH 07/21] feat(volume): add empty volume list endpoint --- internal/api/volume/router.go | 1 + internal/api/volume/volume.go | 13 +++++++++ internal/api/volume/volume_dto.go | 17 ++++++++++++ internal/api/volume/volume_test.go | 43 ++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+) create mode 100644 internal/api/volume/volume.go create mode 100644 internal/api/volume/volume_dto.go create mode 100644 internal/api/volume/volume_test.go diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index 64f27e3..c320e4b 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -23,6 +23,7 @@ func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/{project_id}", h.version) router.Get("/{project_id}/", h.version) + router.Get("/{project_id}/volumes/detail", h.listVolumes) return router } diff --git a/internal/api/volume/volume.go b/internal/api/volume/volume.go new file mode 100644 index 0000000..e4311c8 --- /dev/null +++ b/internal/api/volume/volume.go @@ -0,0 +1,13 @@ +package volume + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listVolumes(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, volumeListResponse{ + Volumes: []volumeDocument{}, + }) +} diff --git a/internal/api/volume/volume_dto.go b/internal/api/volume/volume_dto.go new file mode 100644 index 0000000..019749c --- /dev/null +++ b/internal/api/volume/volume_dto.go @@ -0,0 +1,17 @@ +package volume + +type volumeListResponse struct { + Volumes []volumeDocument `json:"volumes"` +} + +type volumeDocument struct { + ID string `json:"id"` + Status string `json:"status"` + Size int `json:"size"` + Name string `json:"name"` + Description string `json:"description"` + Metadata map[string]string `json:"metadata"` + Bootable string `json:"bootable"` + Encrypted bool `json:"encrypted"` + Multiattach bool `json:"multiattach"` +} diff --git a/internal/api/volume/volume_test.go b/internal/api/volume/volume_test.go new file mode 100644 index 0000000..49b544c --- /dev/null +++ b/internal/api/volume/volume_test.go @@ -0,0 +1,43 @@ +package volume_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/volume" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type VolumeSuite struct { + suite.Suite + server *httptest.Server +} + +func TestVolumeSuite(t *testing.T) { + suite.Run(t, new(VolumeSuite)) +} + +func (s *VolumeSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *VolumeSuite) TearDownTest() { + s.server.Close() +} + +func (s *VolumeSuite) TestListVolumes() { + pages, err := volumes.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := volumes.ExtractVolumes(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} From 4239bbff4bf92059f728d09c3adf168d4dfa9b59 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:10:39 +0900 Subject: [PATCH 08/21] feat(volume): add in-memory create and list --- internal/api/volume/router.go | 10 ++++- internal/api/volume/volume.go | 16 +++++++- internal/api/volume/volume_dto.go | 55 ++++++++++++++++++++++++++++ internal/api/volume/volume_test.go | 32 +++++++++++++++- internal/app/volume/service.go | 59 ++++++++++++++++++++++++++++++ internal/app/volume/types.go | 24 ++++++++++++ 6 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 internal/app/volume/service.go create mode 100644 internal/app/volume/types.go diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index c320e4b..70da19b 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -3,12 +3,14 @@ package volume import ( "net/http" + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/go-chi/chi/v5" ) type Handler struct { - config config.Config + config config.Config + service *appvolume.Service } func NewRouter(cfg config.Config) http.Handler { @@ -16,7 +18,10 @@ func NewRouter(cfg config.Config) http.Handler { } func NewHandler(cfg config.Config) Handler { - return Handler{config: cfg} + return Handler{ + config: cfg, + service: appvolume.NewService(), + } } func (h Handler) Router() http.Handler { @@ -24,6 +29,7 @@ func (h Handler) Router() http.Handler { router.Get("/{project_id}", h.version) router.Get("/{project_id}/", h.version) router.Get("/{project_id}/volumes/detail", h.listVolumes) + router.Post("/{project_id}/volumes", h.createVolume) return router } diff --git a/internal/api/volume/volume.go b/internal/api/volume/volume.go index e4311c8..e52be0e 100644 --- a/internal/api/volume/volume.go +++ b/internal/api/volume/volume.go @@ -1,6 +1,7 @@ package volume import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -8,6 +9,19 @@ import ( func (h Handler) listVolumes(w http.ResponseWriter, r *http.Request) { respond.JSON(w, http.StatusOK, volumeListResponse{ - Volumes: []volumeDocument{}, + Volumes: toVolumeDocuments(h.service.List()), + }) +} + +func (h Handler) createVolume(w http.ResponseWriter, r *http.Request) { + var request createVolumeRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + volume := h.service.Create(request.createVolume()) + respond.JSON(w, http.StatusAccepted, volumeResponse{ + Volume: toVolumeDocument(volume), }) } diff --git a/internal/api/volume/volume_dto.go b/internal/api/volume/volume_dto.go index 019749c..4e5a0dc 100644 --- a/internal/api/volume/volume_dto.go +++ b/internal/api/volume/volume_dto.go @@ -1,17 +1,72 @@ package volume +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type createVolumeRequest struct { + Volume struct { + Size int `json:"size"` + Name string `json:"name"` + Description string `json:"description"` + VolumeType string `json:"volume_type"` + Metadata map[string]string `json:"metadata"` + } `json:"volume"` +} + +func (r createVolumeRequest) createVolume() appvolume.CreateVolume { + return appvolume.CreateVolume{ + Size: r.Volume.Size, + Name: r.Volume.Name, + Description: r.Volume.Description, + VolumeType: r.Volume.VolumeType, + Metadata: r.Volume.Metadata, + } +} + type volumeListResponse struct { Volumes []volumeDocument `json:"volumes"` } +type volumeResponse struct { + Volume volumeDocument `json:"volume"` +} + type volumeDocument struct { ID string `json:"id"` Status string `json:"status"` Size int `json:"size"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` Name string `json:"name"` Description string `json:"description"` + VolumeType string `json:"volume_type"` Metadata map[string]string `json:"metadata"` Bootable string `json:"bootable"` Encrypted bool `json:"encrypted"` Multiattach bool `json:"multiattach"` } + +func toVolumeDocuments(volumes []appvolume.Volume) []volumeDocument { + documents := make([]volumeDocument, 0, len(volumes)) + for _, volume := range volumes { + documents = append(documents, toVolumeDocument(volume)) + } + + return documents +} + +func toVolumeDocument(volume appvolume.Volume) volumeDocument { + return volumeDocument{ + ID: volume.ID, + Status: volume.Status, + Size: volume.Size, + CreatedAt: volume.CreatedAt, + UpdatedAt: volume.UpdatedAt, + Name: volume.Name, + Description: volume.Description, + VolumeType: volume.VolumeType, + Metadata: volume.Metadata, + Bootable: volume.Bootable, + Encrypted: volume.Encrypted, + Multiattach: volume.Multiattach, + } +} diff --git a/internal/api/volume/volume_test.go b/internal/api/volume/volume_test.go index 49b544c..e6309a4 100644 --- a/internal/api/volume/volume_test.go +++ b/internal/api/volume/volume_test.go @@ -30,6 +30,36 @@ func (s *VolumeSuite) TearDownTest() { } func (s *VolumeSuite) TestListVolumes() { + list := s.listVolumes() + + s.Assert().Empty(list) +} + +func (s *VolumeSuite) TestCreateVolumeThenListVolumes() { + created, err := volumes.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + volumes.CreateOpts{ + Size: 1, + Name: "database", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + list := s.listVolumes() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("database", created.Name) + s.Assert().Equal(1, created.Size) + s.Assert().Equal("creating", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("database", list[0].Name) +} + +func (s *VolumeSuite) listVolumes() []volumes.Volume { pages, err := volumes.List( testhelper.ServiceClient(s.server.URL+"/demo"), nil, @@ -39,5 +69,5 @@ func (s *VolumeSuite) TestListVolumes() { list, err := volumes.ExtractVolumes(pages) s.Require().NoError(err) - s.Assert().Empty(list) + return list } diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go new file mode 100644 index 0000000..b9efb25 --- /dev/null +++ b/internal/app/volume/service.go @@ -0,0 +1,59 @@ +package volume + +import ( + "sync" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +const timestampFormat = "2006-01-02T15:04:05.999999" + +type Service struct { + mu sync.RWMutex + ids []string + volumes map[string]Volume +} + +func NewService() *Service { + return &Service{ + ids: []string{}, + volumes: map[string]Volume{}, + } +} + +func (s *Service) Create(input CreateVolume) Volume { + now := time.Now().UTC().Format(timestampFormat) + volume := Volume{ + ID: "vol-" + idgen.RandomHex(16), + Status: "creating", + Size: input.Size, + Name: input.Name, + Description: input.Description, + VolumeType: input.VolumeType, + Metadata: input.Metadata, + CreatedAt: now, + UpdatedAt: now, + Bootable: "false", + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, volume.ID) + s.volumes[volume.ID] = volume + + return volume +} + +func (s *Service) List() []Volume { + s.mu.RLock() + defer s.mu.RUnlock() + + volumes := make([]Volume, 0, len(s.ids)) + for _, id := range s.ids { + volumes = append(volumes, s.volumes[id]) + } + + return volumes +} diff --git a/internal/app/volume/types.go b/internal/app/volume/types.go new file mode 100644 index 0000000..9e21223 --- /dev/null +++ b/internal/app/volume/types.go @@ -0,0 +1,24 @@ +package volume + +type CreateVolume struct { + Size int + Name string + Description string + VolumeType string + Metadata map[string]string +} + +type Volume struct { + ID string + Status string + Size int + Name string + Description string + VolumeType string + Metadata map[string]string + CreatedAt string + UpdatedAt string + Bootable string + Encrypted bool + Multiattach bool +} From bdb25655b121d3065878bf0a08e7db1d4ec718c0 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:11:46 +0900 Subject: [PATCH 09/21] feat(volume): add volume get and delete endpoints --- internal/api/volume/router.go | 2 + internal/api/volume/volume.go | 33 +++++++++++++++++ internal/api/volume/volume_test.go | 59 ++++++++++++++++++++++++------ internal/app/volume/service.go | 34 +++++++++++++++++ 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index 70da19b..0d0867d 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -30,6 +30,8 @@ func (h Handler) Router() http.Handler { router.Get("/{project_id}/", h.version) router.Get("/{project_id}/volumes/detail", h.listVolumes) router.Post("/{project_id}/volumes", h.createVolume) + router.Get("/{project_id}/volumes/{volume_id}", h.getVolume) + router.Delete("/{project_id}/volumes/{volume_id}", h.deleteVolume) return router } diff --git a/internal/api/volume/volume.go b/internal/api/volume/volume.go index e52be0e..450fc31 100644 --- a/internal/api/volume/volume.go +++ b/internal/api/volume/volume.go @@ -2,9 +2,12 @@ package volume import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + "github.com/go-chi/chi/v5" ) func (h Handler) listVolumes(w http.ResponseWriter, r *http.Request) { @@ -25,3 +28,33 @@ func (h Handler) createVolume(w http.ResponseWriter, r *http.Request) { Volume: toVolumeDocument(volume), }) } + +func (h Handler) getVolume(w http.ResponseWriter, r *http.Request) { + volume, err := h.service.Get(chi.URLParam(r, "volume_id")) + if errors.Is(err, appvolume.ErrVolumeNotFound) { + respond.Error(w, http.StatusNotFound, "volume not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "volume lookup failed") + return + } + + respond.JSON(w, http.StatusOK, volumeResponse{ + Volume: toVolumeDocument(volume), + }) +} + +func (h Handler) deleteVolume(w http.ResponseWriter, r *http.Request) { + err := h.service.Delete(chi.URLParam(r, "volume_id")) + if errors.Is(err, appvolume.ErrVolumeNotFound) { + respond.Error(w, http.StatusNotFound, "volume not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "volume delete failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/api/volume/volume_test.go b/internal/api/volume/volume_test.go index e6309a4..de13521 100644 --- a/internal/api/volume/volume_test.go +++ b/internal/api/volume/volume_test.go @@ -36,17 +36,7 @@ func (s *VolumeSuite) TestListVolumes() { } func (s *VolumeSuite) TestCreateVolumeThenListVolumes() { - created, err := volumes.Create( - s.T().Context(), - testhelper.ServiceClient(s.server.URL+"/demo"), - volumes.CreateOpts{ - Size: 1, - Name: "database", - }, - nil, - ).Extract() - s.Require().NoError(err) - s.Require().NotNil(created) + created := s.createVolume("database") list := s.listVolumes() @@ -59,6 +49,37 @@ func (s *VolumeSuite) TestCreateVolumeThenListVolumes() { s.Assert().Equal("database", list[0].Name) } +func (s *VolumeSuite) TestGetVolume() { + created := s.createVolume("database") + + found, err := volumes.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("database", found.Name) +} + +func (s *VolumeSuite) TestDeleteVolume() { + created := s.createVolume("database") + + err := volumes.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + volumes.DeleteOpts{}, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listVolumes() + + s.Assert().Empty(list) +} + func (s *VolumeSuite) listVolumes() []volumes.Volume { pages, err := volumes.List( testhelper.ServiceClient(s.server.URL+"/demo"), @@ -71,3 +92,19 @@ func (s *VolumeSuite) listVolumes() []volumes.Volume { return list } + +func (s *VolumeSuite) createVolume(name string) *volumes.Volume { + created, err := volumes.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + volumes.CreateOpts{ + Size: 1, + Name: name, + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index b9efb25..3c8b815 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -1,6 +1,7 @@ package volume import ( + "errors" "sync" "time" @@ -9,6 +10,8 @@ import ( const timestampFormat = "2006-01-02T15:04:05.999999" +var ErrVolumeNotFound = errors.New("volume not found") + type Service struct { mu sync.RWMutex ids []string @@ -57,3 +60,34 @@ func (s *Service) List() []Volume { return volumes } + +func (s *Service) Get(id string) (Volume, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + volume, ok := s.volumes[id] + if !ok { + return Volume{}, ErrVolumeNotFound + } + + return volume, nil +} + +func (s *Service) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.volumes[id]; !ok { + return ErrVolumeNotFound + } + + delete(s.volumes, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} From ed7a7605848c35ba044dba7c739aefe885524bdd Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:14:21 +0900 Subject: [PATCH 10/21] feat(compute): add default flavor endpoints --- internal/api/compute/flavor.go | 32 +++++++++++++++ internal/api/compute/flavor_dto.go | 50 +++++++++++++++++++++++ internal/api/compute/flavor_test.go | 62 +++++++++++++++++++++++++++++ internal/api/compute/router.go | 12 +++++- internal/app/compute/service.go | 46 +++++++++++++++++++++ internal/app/compute/types.go | 15 +++++++ 6 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 internal/api/compute/flavor.go create mode 100644 internal/api/compute/flavor_dto.go create mode 100644 internal/api/compute/flavor_test.go create mode 100644 internal/app/compute/service.go create mode 100644 internal/app/compute/types.go diff --git a/internal/api/compute/flavor.go b/internal/api/compute/flavor.go new file mode 100644 index 0000000..9b548c7 --- /dev/null +++ b/internal/api/compute/flavor.go @@ -0,0 +1,32 @@ +package compute + +import ( + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listFlavors(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, flavorListResponse{ + Flavors: toFlavorDocuments(h.service.ListFlavors()), + }) +} + +func (h Handler) getFlavor(w http.ResponseWriter, r *http.Request) { + flavor, err := h.service.GetFlavor(chi.URLParam(r, "flavor_id")) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor lookup failed") + return + } + + respond.JSON(w, http.StatusOK, flavorResponse{ + Flavor: toFlavorDocument(flavor), + }) +} diff --git a/internal/api/compute/flavor_dto.go b/internal/api/compute/flavor_dto.go new file mode 100644 index 0000000..04c7721 --- /dev/null +++ b/internal/api/compute/flavor_dto.go @@ -0,0 +1,50 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type flavorListResponse struct { + Flavors []flavorDocument `json:"flavors"` +} + +type flavorResponse struct { + Flavor flavorDocument `json:"flavor"` +} + +type flavorDocument struct { + ID string `json:"id"` + Name string `json:"name"` + RAM int `json:"ram"` + VCPUs int `json:"vcpus"` + Disk int `json:"disk"` + Swap int `json:"swap"` + RxTxFactor float64 `json:"rxtx_factor"` + IsPublic bool `json:"os-flavor-access:is_public"` + Ephemeral int `json:"OS-FLV-EXT-DATA:ephemeral"` + Description string `json:"description"` + ExtraSpecs map[string]string `json:"extra_specs"` +} + +func toFlavorDocuments(flavors []appcompute.Flavor) []flavorDocument { + documents := make([]flavorDocument, 0, len(flavors)) + for _, flavor := range flavors { + documents = append(documents, toFlavorDocument(flavor)) + } + + return documents +} + +func toFlavorDocument(flavor appcompute.Flavor) flavorDocument { + return flavorDocument{ + ID: flavor.ID, + Name: flavor.Name, + RAM: flavor.RAM, + VCPUs: flavor.VCPUs, + Disk: flavor.Disk, + Swap: flavor.Swap, + RxTxFactor: flavor.RxTxFactor, + IsPublic: flavor.IsPublic, + Ephemeral: flavor.Ephemeral, + Description: flavor.Description, + ExtraSpecs: flavor.ExtraSpecs, + } +} diff --git a/internal/api/compute/flavor_test.go b/internal/api/compute/flavor_test.go new file mode 100644 index 0000000..dec501e --- /dev/null +++ b/internal/api/compute/flavor_test.go @@ -0,0 +1,62 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/stretchr/testify/suite" +) + +type FlavorSuite struct { + suite.Suite + server *httptest.Server +} + +func TestFlavorSuite(t *testing.T) { + suite.Run(t, new(FlavorSuite)) +} + +func (s *FlavorSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *FlavorSuite) TearDownTest() { + s.server.Close() +} + +func (s *FlavorSuite) TestListFlavors() { + pages, err := flavors.ListDetail( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := flavors.ExtractFlavors(pages) + s.Require().NoError(err) + + s.Require().Len(list, 1) + s.Assert().Equal("1", list[0].ID) + s.Assert().Equal("m1.small", list[0].Name) + s.Assert().Equal(2048, list[0].RAM) + s.Assert().Equal(1, list[0].VCPUs) + s.Assert().Equal(20, list[0].Disk) +} + +func (s *FlavorSuite) TestGetFlavor() { + flavor, err := flavors.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + "1", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(flavor) + + s.Assert().Equal("1", flavor.ID) + s.Assert().Equal("m1.small", flavor.Name) + s.Assert().Equal(true, flavor.IsPublic) +} diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index dd2b8b3..8432571 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -3,12 +3,14 @@ package compute import ( "net/http" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/go-chi/chi/v5" ) type Handler struct { - config config.Config + config config.Config + service *appcompute.Service } func NewRouter(cfg config.Config) http.Handler { @@ -16,13 +18,19 @@ func NewRouter(cfg config.Config) http.Handler { } func NewHandler(cfg config.Config) Handler { - return Handler{config: cfg} + return Handler{ + config: cfg, + service: appcompute.NewService(), + } } func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/{project_id}", h.version) router.Get("/{project_id}/", h.version) + router.Get("/{project_id}/flavors", h.listFlavors) + router.Get("/{project_id}/flavors/detail", h.listFlavors) + router.Get("/{project_id}/flavors/{flavor_id}", h.getFlavor) return router } diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go new file mode 100644 index 0000000..0f51021 --- /dev/null +++ b/internal/app/compute/service.go @@ -0,0 +1,46 @@ +package compute + +import "errors" + +var ErrFlavorNotFound = errors.New("flavor not found") + +type Service struct { + flavors []Flavor +} + +func NewService() *Service { + return &Service{ + flavors: []Flavor{ + { + ID: "1", + Name: "m1.small", + RAM: 2048, + VCPUs: 1, + Disk: 20, + Swap: 0, + RxTxFactor: 1.0, + IsPublic: true, + Ephemeral: 0, + Description: "Small test flavor", + ExtraSpecs: map[string]string{}, + }, + }, + } +} + +func (s *Service) ListFlavors() []Flavor { + flavors := make([]Flavor, 0, len(s.flavors)) + flavors = append(flavors, s.flavors...) + + return flavors +} + +func (s *Service) GetFlavor(id string) (Flavor, error) { + for _, flavor := range s.flavors { + if flavor.ID == id { + return flavor, nil + } + } + + return Flavor{}, ErrFlavorNotFound +} diff --git a/internal/app/compute/types.go b/internal/app/compute/types.go new file mode 100644 index 0000000..c984a39 --- /dev/null +++ b/internal/app/compute/types.go @@ -0,0 +1,15 @@ +package compute + +type Flavor struct { + ID string + Name string + RAM int + VCPUs int + Disk int + Swap int + RxTxFactor float64 + IsPublic bool + Ephemeral int + Description string + ExtraSpecs map[string]string +} From ad2d2818abb55079001af5e80d12dde640d94a82 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:15:32 +0900 Subject: [PATCH 11/21] feat(compute): add empty server list endpoints --- internal/api/compute/router.go | 2 ++ internal/api/compute/server.go | 13 +++++++ internal/api/compute/server_dto.go | 28 +++++++++++++++ internal/api/compute/server_test.go | 56 +++++++++++++++++++++++++++++ internal/app/compute/service.go | 4 +++ internal/app/compute/types.go | 5 +++ 6 files changed, 108 insertions(+) create mode 100644 internal/api/compute/server.go create mode 100644 internal/api/compute/server_dto.go create mode 100644 internal/api/compute/server_test.go diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index 8432571..b54f12b 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -31,6 +31,8 @@ func (h Handler) Router() http.Handler { router.Get("/{project_id}/flavors", h.listFlavors) router.Get("/{project_id}/flavors/detail", h.listFlavors) router.Get("/{project_id}/flavors/{flavor_id}", h.getFlavor) + router.Get("/{project_id}/servers", h.listServers) + router.Get("/{project_id}/servers/detail", h.listServers) return router } diff --git a/internal/api/compute/server.go b/internal/api/compute/server.go new file mode 100644 index 0000000..daab140 --- /dev/null +++ b/internal/api/compute/server.go @@ -0,0 +1,13 @@ +package compute + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listServers(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, serverListResponse{ + Servers: toServerDocuments(h.service.ListServers()), + }) +} diff --git a/internal/api/compute/server_dto.go b/internal/api/compute/server_dto.go new file mode 100644 index 0000000..047d559 --- /dev/null +++ b/internal/api/compute/server_dto.go @@ -0,0 +1,28 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type serverListResponse struct { + Servers []serverDocument `json:"servers"` +} + +type serverDocument struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func toServerDocuments(servers []appcompute.Server) []serverDocument { + documents := make([]serverDocument, 0, len(servers)) + for _, server := range servers { + documents = append(documents, toServerDocument(server)) + } + + return documents +} + +func toServerDocument(server appcompute.Server) serverDocument { + return serverDocument{ + ID: server.ID, + Name: server.Name, + } +} diff --git a/internal/api/compute/server_test.go b/internal/api/compute/server_test.go new file mode 100644 index 0000000..118e836 --- /dev/null +++ b/internal/api/compute/server_test.go @@ -0,0 +1,56 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/stretchr/testify/suite" +) + +type ServerSuite struct { + suite.Suite + server *httptest.Server +} + +func TestServerSuite(t *testing.T) { + suite.Run(t, new(ServerSuite)) +} + +func (s *ServerSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ServerSuite) TearDownTest() { + s.server.Close() +} + +func (s *ServerSuite) TestListServers() { + pages, err := servers.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := servers.ExtractServers(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} + +func (s *ServerSuite) TestListSimpleServers() { + pages, err := servers.ListSimple( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := servers.ExtractServers(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index 0f51021..7fde17c 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -44,3 +44,7 @@ func (s *Service) GetFlavor(id string) (Flavor, error) { return Flavor{}, ErrFlavorNotFound } + +func (s *Service) ListServers() []Server { + return []Server{} +} diff --git a/internal/app/compute/types.go b/internal/app/compute/types.go index c984a39..047dbb1 100644 --- a/internal/app/compute/types.go +++ b/internal/app/compute/types.go @@ -13,3 +13,8 @@ type Flavor struct { Description string ExtraSpecs map[string]string } + +type Server struct { + ID string + Name string +} From 7ec5fda2e24baabb66a93ba43cfe0286ee3ed488 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:18:12 +0900 Subject: [PATCH 12/21] feat(compute): add in-memory create and list --- internal/api/compute/router.go | 1 + internal/api/compute/server.go | 14 +++++++ internal/api/compute/server_dto.go | 59 +++++++++++++++++++++++++++-- internal/api/compute/server_test.go | 37 ++++++++++++++++++ internal/app/compute/service.go | 48 ++++++++++++++++++++++- internal/app/compute/types.go | 20 +++++++++- 6 files changed, 171 insertions(+), 8 deletions(-) diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index b54f12b..b976126 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -32,6 +32,7 @@ func (h Handler) Router() http.Handler { router.Get("/{project_id}/flavors/detail", h.listFlavors) router.Get("/{project_id}/flavors/{flavor_id}", h.getFlavor) router.Get("/{project_id}/servers", h.listServers) + router.Post("/{project_id}/servers", h.createServer) router.Get("/{project_id}/servers/detail", h.listServers) return router diff --git a/internal/api/compute/server.go b/internal/api/compute/server.go index daab140..4add62f 100644 --- a/internal/api/compute/server.go +++ b/internal/api/compute/server.go @@ -1,6 +1,7 @@ package compute import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -11,3 +12,16 @@ func (h Handler) listServers(w http.ResponseWriter, _ *http.Request) { Servers: toServerDocuments(h.service.ListServers()), }) } + +func (h Handler) createServer(w http.ResponseWriter, r *http.Request) { + var request createServerRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + server := h.service.CreateServer(request.createServer()) + respond.JSON(w, http.StatusAccepted, serverResponse{ + Server: toServerDocument(server), + }) +} diff --git a/internal/api/compute/server_dto.go b/internal/api/compute/server_dto.go index 047d559..ae90104 100644 --- a/internal/api/compute/server_dto.go +++ b/internal/api/compute/server_dto.go @@ -2,13 +2,48 @@ package compute import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" +type createServerRequest struct { + Server createServerDocument `json:"server"` +} + +type createServerDocument struct { + Name string `json:"name"` + ImageRef string `json:"imageRef"` + FlavorRef string `json:"flavorRef"` + Metadata map[string]string `json:"metadata"` +} + +func (r createServerRequest) createServer() appcompute.CreateServer { + return appcompute.CreateServer{ + Name: r.Server.Name, + ImageID: r.Server.ImageRef, + FlavorID: r.Server.FlavorRef, + Metadata: r.Server.Metadata, + } +} + type serverListResponse struct { Servers []serverDocument `json:"servers"` } +type serverResponse struct { + Server serverDocument `json:"server"` +} + type serverDocument struct { - ID string `json:"id"` - Name string `json:"name"` + ID string `json:"id"` + Name string `json:"name"` + Image map[string]any `json:"image"` + Flavor map[string]any `json:"flavor"` + TenantID string `json:"tenant_id"` + UserID string `json:"user_id"` + Status string `json:"status"` + Progress int `json:"progress"` + CreatedAt string `json:"created"` + UpdatedAt string `json:"updated"` + Addresses map[string]any `json:"addresses"` + Metadata map[string]string `json:"metadata"` + Links []map[string]any `json:"links"` } func toServerDocuments(servers []appcompute.Server) []serverDocument { @@ -22,7 +57,23 @@ func toServerDocuments(servers []appcompute.Server) []serverDocument { func toServerDocument(server appcompute.Server) serverDocument { return serverDocument{ - ID: server.ID, - Name: server.Name, + ID: server.ID, + Name: server.Name, + Image: map[string]any{"id": server.ImageID}, + Flavor: map[string]any{"id": server.FlavorID}, + TenantID: server.TenantID, + UserID: server.UserID, + Status: server.Status, + Progress: server.Progress, + CreatedAt: server.CreatedAt, + UpdatedAt: server.UpdatedAt, + Addresses: map[string]any{}, + Metadata: server.Metadata, + Links: []map[string]any{ + { + "href": "/servers/" + server.ID, + "rel": "self", + }, + }, } } diff --git a/internal/api/compute/server_test.go b/internal/api/compute/server_test.go index 118e836..2d13bd4 100644 --- a/internal/api/compute/server_test.go +++ b/internal/api/compute/server_test.go @@ -54,3 +54,40 @@ func (s *ServerSuite) TestListSimpleServers() { s.Assert().Empty(list) } + +func (s *ServerSuite) TestCreateServerThenListServers() { + created := s.createServer("test-server") + + pages, err := servers.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := servers.ExtractServers(pages) + s.Require().NoError(err) + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("test-server", created.Name) + s.Assert().Equal("BUILD", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("test-server", list[0].Name) +} + +func (s *ServerSuite) createServer(name string) *servers.Server { + created, err := servers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + servers.CreateOpts{ + Name: name, + ImageRef: "img-1", + FlavorRef: "1", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index 7fde17c..c5fdc60 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -1,11 +1,20 @@ package compute -import "errors" +import ( + "errors" + "sync" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) var ErrFlavorNotFound = errors.New("flavor not found") type Service struct { flavors []Flavor + mu sync.RWMutex + ids []string + servers map[string]Server } func NewService() *Service { @@ -25,6 +34,8 @@ func NewService() *Service { ExtraSpecs: map[string]string{}, }, }, + ids: []string{}, + servers: map[string]Server{}, } } @@ -46,5 +57,38 @@ func (s *Service) GetFlavor(id string) (Flavor, error) { } func (s *Service) ListServers() []Server { - return []Server{} + s.mu.RLock() + defer s.mu.RUnlock() + + servers := make([]Server, 0, len(s.ids)) + for _, id := range s.ids { + servers = append(servers, s.servers[id]) + } + + return servers +} + +func (s *Service) CreateServer(input CreateServer) Server { + now := time.Now().UTC().Format(time.RFC3339) + server := Server{ + ID: "srv-" + idgen.RandomHex(16), + Name: input.Name, + ImageID: input.ImageID, + FlavorID: input.FlavorID, + TenantID: "demo", + UserID: "admin", + Status: "BUILD", + Progress: 0, + CreatedAt: now, + UpdatedAt: now, + Metadata: input.Metadata, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, server.ID) + s.servers[server.ID] = server + + return server } diff --git a/internal/app/compute/types.go b/internal/app/compute/types.go index 047dbb1..19e4b97 100644 --- a/internal/app/compute/types.go +++ b/internal/app/compute/types.go @@ -15,6 +15,22 @@ type Flavor struct { } type Server struct { - ID string - Name string + ID string + Name string + ImageID string + FlavorID string + TenantID string + UserID string + Status string + Progress int + CreatedAt string + UpdatedAt string + Metadata map[string]string +} + +type CreateServer struct { + Name string + ImageID string + FlavorID string + Metadata map[string]string } From a78a57349d260c136e51bcfb2188a1bcec00b601 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:19:06 +0900 Subject: [PATCH 13/21] feat(compute): add server get and delete endpoints --- internal/api/compute/router.go | 2 ++ internal/api/compute/server.go | 33 +++++++++++++++++++++++++ internal/api/compute/server_test.go | 37 +++++++++++++++++++++++++++++ internal/app/compute/service.go | 32 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index b976126..4101a71 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -34,6 +34,8 @@ func (h Handler) Router() http.Handler { router.Get("/{project_id}/servers", h.listServers) router.Post("/{project_id}/servers", h.createServer) router.Get("/{project_id}/servers/detail", h.listServers) + router.Get("/{project_id}/servers/{server_id}", h.getServer) + router.Delete("/{project_id}/servers/{server_id}", h.deleteServer) return router } diff --git a/internal/api/compute/server.go b/internal/api/compute/server.go index 4add62f..a175cf2 100644 --- a/internal/api/compute/server.go +++ b/internal/api/compute/server.go @@ -2,9 +2,12 @@ package compute import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" ) func (h Handler) listServers(w http.ResponseWriter, _ *http.Request) { @@ -25,3 +28,33 @@ func (h Handler) createServer(w http.ResponseWriter, r *http.Request) { Server: toServerDocument(server), }) } + +func (h Handler) getServer(w http.ResponseWriter, r *http.Request) { + server, err := h.service.GetServer(chi.URLParam(r, "server_id")) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server lookup failed") + return + } + + respond.JSON(w, http.StatusOK, serverResponse{ + Server: toServerDocument(server), + }) +} + +func (h Handler) deleteServer(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteServer(chi.URLParam(r, "server_id")) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/compute/server_test.go b/internal/api/compute/server_test.go index 2d13bd4..63b68b5 100644 --- a/internal/api/compute/server_test.go +++ b/internal/api/compute/server_test.go @@ -75,6 +75,43 @@ func (s *ServerSuite) TestCreateServerThenListServers() { s.Assert().Equal("test-server", list[0].Name) } +func (s *ServerSuite) TestGetServer() { + created := s.createServer("test-server") + + found, err := servers.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("test-server", found.Name) +} + +func (s *ServerSuite) TestDeleteServer() { + created := s.createServer("test-server") + + err := servers.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + pages, err := servers.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := servers.ExtractServers(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} + func (s *ServerSuite) createServer(name string) *servers.Server { created, err := servers.Create( s.T().Context(), diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index c5fdc60..8438cdb 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -9,6 +9,7 @@ import ( ) var ErrFlavorNotFound = errors.New("flavor not found") +var ErrServerNotFound = errors.New("server not found") type Service struct { flavors []Flavor @@ -92,3 +93,34 @@ func (s *Service) CreateServer(input CreateServer) Server { return server } + +func (s *Service) GetServer(id string) (Server, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + server, ok := s.servers[id] + if !ok { + return Server{}, ErrServerNotFound + } + + return server, nil +} + +func (s *Service) DeleteServer(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.servers[id]; !ok { + return ErrServerNotFound + } + + delete(s.servers, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} From 98f3ce33b4a01c15b09c9fbb5c51333d3c7ed9e6 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:20:12 +0900 Subject: [PATCH 14/21] feat(network): add empty subnet list endpoint --- internal/api/network/router.go | 1 + internal/api/network/subnet.go | 13 +++++++++ internal/api/network/subnet_dto.go | 30 ++++++++++++++++++++ internal/api/network/subnet_test.go | 43 +++++++++++++++++++++++++++++ internal/app/network/service.go | 4 +++ internal/app/network/types.go | 6 ++++ 6 files changed, 97 insertions(+) create mode 100644 internal/api/network/subnet.go create mode 100644 internal/api/network/subnet_dto.go create mode 100644 internal/api/network/subnet_test.go diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 02cb982..971e4e6 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -31,6 +31,7 @@ func (h Handler) Router() http.Handler { router.Post("/networks", h.createNetwork) router.Get("/networks/{network_id}", h.getNetwork) router.Delete("/networks/{network_id}", h.deleteNetwork) + router.Get("/subnets", h.listSubnets) return router } diff --git a/internal/api/network/subnet.go b/internal/api/network/subnet.go new file mode 100644 index 0000000..4491c09 --- /dev/null +++ b/internal/api/network/subnet.go @@ -0,0 +1,13 @@ +package network + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listSubnets(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, subnetListResponse{ + Subnets: toSubnetDocuments(h.service.ListSubnets()), + }) +} diff --git a/internal/api/network/subnet_dto.go b/internal/api/network/subnet_dto.go new file mode 100644 index 0000000..df88259 --- /dev/null +++ b/internal/api/network/subnet_dto.go @@ -0,0 +1,30 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type subnetListResponse struct { + Subnets []subnetDocument `json:"subnets"` +} + +type subnetDocument struct { + ID string `json:"id"` + NetworkID string `json:"network_id"` + Name string `json:"name"` +} + +func toSubnetDocuments(subnets []appnetwork.Subnet) []subnetDocument { + documents := make([]subnetDocument, 0, len(subnets)) + for _, subnet := range subnets { + documents = append(documents, toSubnetDocument(subnet)) + } + + return documents +} + +func toSubnetDocument(subnet appnetwork.Subnet) subnetDocument { + return subnetDocument{ + ID: subnet.ID, + NetworkID: subnet.NetworkID, + Name: subnet.Name, + } +} diff --git a/internal/api/network/subnet_test.go b/internal/api/network/subnet_test.go new file mode 100644 index 0000000..02f58c8 --- /dev/null +++ b/internal/api/network/subnet_test.go @@ -0,0 +1,43 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets" + "github.com/stretchr/testify/suite" +) + +type SubnetSuite struct { + suite.Suite + server *httptest.Server +} + +func TestSubnetSuite(t *testing.T) { + suite.Run(t, new(SubnetSuite)) +} + +func (s *SubnetSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *SubnetSuite) TearDownTest() { + s.server.Close() +} + +func (s *SubnetSuite) TestListSubnets() { + pages, err := subnets.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := subnets.ExtractSubnets(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 98cc41e..d78d50f 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -91,3 +91,7 @@ func (s *Service) Delete(id string) error { return nil } + +func (s *Service) ListSubnets() []Subnet { + return []Subnet{} +} diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 3b079c0..28d51fb 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -19,3 +19,9 @@ type Network struct { ProjectID string Shared bool } + +type Subnet struct { + ID string + NetworkID string + Name string +} From 2e29ad8ca2ea9a52a354ad7a2320c4bb306cd3e7 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:22:08 +0900 Subject: [PATCH 15/21] feat(network): add in-memory subnet create and list --- internal/api/network/router.go | 1 + internal/api/network/subnet.go | 14 ++++ internal/api/network/subnet_dto.go | 113 ++++++++++++++++++++++++++-- internal/api/network/subnet_test.go | 66 +++++++++++++++- internal/app/network/service.go | 59 +++++++++++++-- internal/app/network/types.go | 38 +++++++++- 6 files changed, 275 insertions(+), 16 deletions(-) diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 971e4e6..089a566 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -32,6 +32,7 @@ func (h Handler) Router() http.Handler { router.Get("/networks/{network_id}", h.getNetwork) router.Delete("/networks/{network_id}", h.deleteNetwork) router.Get("/subnets", h.listSubnets) + router.Post("/subnets", h.createSubnet) return router } diff --git a/internal/api/network/subnet.go b/internal/api/network/subnet.go index 4491c09..c797817 100644 --- a/internal/api/network/subnet.go +++ b/internal/api/network/subnet.go @@ -1,6 +1,7 @@ package network import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -11,3 +12,16 @@ func (h Handler) listSubnets(w http.ResponseWriter, _ *http.Request) { Subnets: toSubnetDocuments(h.service.ListSubnets()), }) } + +func (h Handler) createSubnet(w http.ResponseWriter, r *http.Request) { + var request createSubnetRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + subnet := h.service.CreateSubnet(request.createSubnet()) + respond.JSON(w, http.StatusCreated, subnetResponse{ + Subnet: toSubnetDocument(subnet), + }) +} diff --git a/internal/api/network/subnet_dto.go b/internal/api/network/subnet_dto.go index df88259..71beb70 100644 --- a/internal/api/network/subnet_dto.go +++ b/internal/api/network/subnet_dto.go @@ -2,14 +2,79 @@ package network import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +type createSubnetRequest struct { + Subnet createSubnetDocument `json:"subnet"` +} + +type createSubnetDocument struct { + NetworkID string `json:"network_id"` + Name string `json:"name"` + Description string `json:"description"` + IPVersion int `json:"ip_version"` + CIDR string `json:"cidr"` + GatewayIP *string `json:"gateway_ip"` + DNSNameservers []string `json:"dns_nameservers"` + EnableDHCP *bool `json:"enable_dhcp"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` +} + +func (r createSubnetRequest) createSubnet() appnetwork.CreateSubnet { + projectID := r.Subnet.ProjectID + if projectID == "" { + projectID = r.Subnet.TenantID + } + + gatewayIP := "" + if r.Subnet.GatewayIP != nil { + gatewayIP = *r.Subnet.GatewayIP + } + + return appnetwork.CreateSubnet{ + NetworkID: r.Subnet.NetworkID, + Name: r.Subnet.Name, + Description: r.Subnet.Description, + IPVersion: r.Subnet.IPVersion, + CIDR: r.Subnet.CIDR, + GatewayIP: gatewayIP, + DNSNameservers: r.Subnet.DNSNameservers, + EnableDHCP: r.Subnet.EnableDHCP, + ProjectID: projectID, + } +} + type subnetListResponse struct { Subnets []subnetDocument `json:"subnets"` } +type subnetResponse struct { + Subnet subnetDocument `json:"subnet"` +} + type subnetDocument struct { - ID string `json:"id"` - NetworkID string `json:"network_id"` - Name string `json:"name"` + ID string `json:"id"` + NetworkID string `json:"network_id"` + Name string `json:"name"` + Description string `json:"description"` + IPVersion int `json:"ip_version"` + CIDR string `json:"cidr"` + GatewayIP string `json:"gateway_ip"` + DNSNameservers []string `json:"dns_nameservers"` + AllocationPools []allocationPoolObject `json:"allocation_pools"` + HostRoutes []hostRouteObject `json:"host_routes"` + EnableDHCP bool `json:"enable_dhcp"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` +} + +type allocationPoolObject struct { + Start string `json:"start"` + End string `json:"end"` +} + +type hostRouteObject struct { + DestinationCIDR string `json:"destination"` + NextHop string `json:"nexthop"` } func toSubnetDocuments(subnets []appnetwork.Subnet) []subnetDocument { @@ -23,8 +88,44 @@ func toSubnetDocuments(subnets []appnetwork.Subnet) []subnetDocument { func toSubnetDocument(subnet appnetwork.Subnet) subnetDocument { return subnetDocument{ - ID: subnet.ID, - NetworkID: subnet.NetworkID, - Name: subnet.Name, + ID: subnet.ID, + NetworkID: subnet.NetworkID, + Name: subnet.Name, + Description: subnet.Description, + IPVersion: subnet.IPVersion, + CIDR: subnet.CIDR, + GatewayIP: subnet.GatewayIP, + DNSNameservers: subnet.DNSNameservers, + AllocationPools: toAllocationPoolObjects(subnet.AllocationPools), + HostRoutes: toHostRouteObjects(subnet.HostRoutes), + EnableDHCP: subnet.EnableDHCP, + TenantID: subnet.TenantID, + ProjectID: subnet.ProjectID, + } +} + +func toAllocationPoolObjects( + pools []appnetwork.AllocationPool, +) []allocationPoolObject { + objects := make([]allocationPoolObject, 0, len(pools)) + for _, pool := range pools { + objects = append(objects, allocationPoolObject{ + Start: pool.Start, + End: pool.End, + }) + } + + return objects +} + +func toHostRouteObjects(routes []appnetwork.HostRoute) []hostRouteObject { + objects := make([]hostRouteObject, 0, len(routes)) + for _, route := range routes { + objects = append(objects, hostRouteObject{ + DestinationCIDR: route.DestinationCIDR, + NextHop: route.NextHop, + }) } + + return objects } diff --git a/internal/api/network/subnet_test.go b/internal/api/network/subnet_test.go index 02f58c8..69d7d54 100644 --- a/internal/api/network/subnet_test.go +++ b/internal/api/network/subnet_test.go @@ -6,6 +6,8 @@ import ( "github.com/JSYoo5B/SandStack/internal/api/network" "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets" "github.com/stretchr/testify/suite" ) @@ -30,6 +32,27 @@ func (s *SubnetSuite) TearDownTest() { } func (s *SubnetSuite) TestListSubnets() { + list := s.listSubnets() + + s.Assert().Empty(list) +} + +func (s *SubnetSuite) TestCreateSubnetThenListSubnets() { + network := s.createNetwork("private") + + created := s.createSubnet(network.ID, "private-subnet") + list := s.listSubnets() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal(network.ID, created.NetworkID) + s.Assert().Equal("private-subnet", created.Name) + s.Assert().Equal("192.168.10.0/24", created.CIDR) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal(network.ID, list[0].NetworkID) +} + +func (s *SubnetSuite) listSubnets() []subnets.Subnet { pages, err := subnets.List( testhelper.ServiceClient(s.server.URL), nil, @@ -39,5 +62,46 @@ func (s *SubnetSuite) TestListSubnets() { list, err := subnets.ExtractSubnets(pages) s.Require().NoError(err) - s.Assert().Empty(list) + return list +} + +func (s *SubnetSuite) createNetwork(name string) *networks.Network { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + networks.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *SubnetSuite) createSubnet( + networkID string, + name string, +) *subnets.Subnet { + created, err := subnets.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + subnets.CreateOpts{ + NetworkID: networkID, + Name: name, + CIDR: "192.168.10.0/24", + IPVersion: gophercloud.IPv4, + ProjectID: "demo", + EnableDHCP: boolPtr(true), + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func boolPtr(value bool) *bool { + return &value } diff --git a/internal/app/network/service.go b/internal/app/network/service.go index d78d50f..499ecd9 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -10,15 +10,19 @@ import ( var ErrNetworkNotFound = errors.New("network not found") type Service struct { - mu sync.RWMutex - ids []string - networks map[string]Network + mu sync.RWMutex + ids []string + networks map[string]Network + subnetIDs []string + subnets map[string]Subnet } func NewService() *Service { return &Service{ - ids: []string{}, - networks: map[string]Network{}, + ids: []string{}, + networks: map[string]Network{}, + subnetIDs: []string{}, + subnets: map[string]Subnet{}, } } @@ -93,5 +97,48 @@ func (s *Service) Delete(id string) error { } func (s *Service) ListSubnets() []Subnet { - return []Subnet{} + s.mu.RLock() + defer s.mu.RUnlock() + + subnets := make([]Subnet, 0, len(s.subnetIDs)) + for _, id := range s.subnetIDs { + subnets = append(subnets, s.subnets[id]) + } + + return subnets +} + +func (s *Service) CreateSubnet(input CreateSubnet) Subnet { + enableDHCP := true + if input.EnableDHCP != nil { + enableDHCP = *input.EnableDHCP + } + + subnet := Subnet{ + ID: "subnet-" + idgen.RandomHex(16), + NetworkID: input.NetworkID, + Name: input.Name, + Description: input.Description, + IPVersion: input.IPVersion, + CIDR: input.CIDR, + GatewayIP: input.GatewayIP, + DNSNameservers: input.DNSNameservers, + EnableDHCP: enableDHCP, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnetIDs = append(s.subnetIDs, subnet.ID) + s.subnets[subnet.ID] = subnet + + network, ok := s.networks[subnet.NetworkID] + if ok { + network.Subnets = append(network.Subnets, subnet.ID) + s.networks[subnet.NetworkID] = network + } + + return subnet } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 28d51fb..b6a4935 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -21,7 +21,39 @@ type Network struct { } type Subnet struct { - ID string - NetworkID string - Name string + ID string + NetworkID string + Name string + Description string + IPVersion int + CIDR string + GatewayIP string + DNSNameservers []string + AllocationPools []AllocationPool + HostRoutes []HostRoute + EnableDHCP bool + TenantID string + ProjectID string +} + +type CreateSubnet struct { + NetworkID string + Name string + Description string + IPVersion int + CIDR string + GatewayIP string + DNSNameservers []string + EnableDHCP *bool + ProjectID string +} + +type AllocationPool struct { + Start string + End string +} + +type HostRoute struct { + DestinationCIDR string + NextHop string } From a903824f74aa9d14c7c29c5fb4cfbea767e6a526 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:22:57 +0900 Subject: [PATCH 16/21] feat(network): add subnet get and delete endpoints --- internal/api/network/router.go | 2 ++ internal/api/network/subnet.go | 33 +++++++++++++++++++ internal/api/network/subnet_test.go | 33 +++++++++++++++++++ internal/app/network/service.go | 49 +++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+) diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 089a566..7fef4f7 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -33,6 +33,8 @@ func (h Handler) Router() http.Handler { router.Delete("/networks/{network_id}", h.deleteNetwork) router.Get("/subnets", h.listSubnets) router.Post("/subnets", h.createSubnet) + router.Get("/subnets/{subnet_id}", h.getSubnet) + router.Delete("/subnets/{subnet_id}", h.deleteSubnet) return router } diff --git a/internal/api/network/subnet.go b/internal/api/network/subnet.go index c797817..22583e3 100644 --- a/internal/api/network/subnet.go +++ b/internal/api/network/subnet.go @@ -2,9 +2,12 @@ package network import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" ) func (h Handler) listSubnets(w http.ResponseWriter, _ *http.Request) { @@ -25,3 +28,33 @@ func (h Handler) createSubnet(w http.ResponseWriter, r *http.Request) { Subnet: toSubnetDocument(subnet), }) } + +func (h Handler) getSubnet(w http.ResponseWriter, r *http.Request) { + subnet, err := h.service.GetSubnet(chi.URLParam(r, "subnet_id")) + if errors.Is(err, appnetwork.ErrSubnetNotFound) { + respond.Error(w, http.StatusNotFound, "subnet not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "subnet lookup failed") + return + } + + respond.JSON(w, http.StatusOK, subnetResponse{ + Subnet: toSubnetDocument(subnet), + }) +} + +func (h Handler) deleteSubnet(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteSubnet(chi.URLParam(r, "subnet_id")) + if errors.Is(err, appnetwork.ErrSubnetNotFound) { + respond.Error(w, http.StatusNotFound, "subnet not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "subnet delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/subnet_test.go b/internal/api/network/subnet_test.go index 69d7d54..fae6206 100644 --- a/internal/api/network/subnet_test.go +++ b/internal/api/network/subnet_test.go @@ -52,6 +52,39 @@ func (s *SubnetSuite) TestCreateSubnetThenListSubnets() { s.Assert().Equal(network.ID, list[0].NetworkID) } +func (s *SubnetSuite) TestGetSubnet() { + network := s.createNetwork("private") + created := s.createSubnet(network.ID, "private-subnet") + + found, err := subnets.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal(network.ID, found.NetworkID) + s.Assert().Equal("private-subnet", found.Name) +} + +func (s *SubnetSuite) TestDeleteSubnet() { + network := s.createNetwork("private") + created := s.createSubnet(network.ID, "private-subnet") + + err := subnets.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listSubnets() + + s.Assert().Empty(list) +} + func (s *SubnetSuite) listSubnets() []subnets.Subnet { pages, err := subnets.List( testhelper.ServiceClient(s.server.URL), diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 499ecd9..ded9a8b 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -8,6 +8,7 @@ import ( ) var ErrNetworkNotFound = errors.New("network not found") +var ErrSubnetNotFound = errors.New("subnet not found") type Service struct { mu sync.RWMutex @@ -142,3 +143,51 @@ func (s *Service) CreateSubnet(input CreateSubnet) Subnet { return subnet } + +func (s *Service) GetSubnet(id string) (Subnet, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + subnet, ok := s.subnets[id] + if !ok { + return Subnet{}, ErrSubnetNotFound + } + + return subnet, nil +} + +func (s *Service) DeleteSubnet(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + subnet, ok := s.subnets[id] + if !ok { + return ErrSubnetNotFound + } + + delete(s.subnets, id) + for index, currentID := range s.subnetIDs { + if currentID == id { + s.subnetIDs = append(s.subnetIDs[:index], s.subnetIDs[index+1:]...) + break + } + } + + network, ok := s.networks[subnet.NetworkID] + if ok { + network.Subnets = removeString(network.Subnets, id) + s.networks[subnet.NetworkID] = network + } + + return nil +} + +func removeString(values []string, target string) []string { + for index, value := range values { + if value == target { + return append(values[:index], values[index+1:]...) + } + } + + return values +} From 88a34f48ef76e6ace87354a11ed128fa0a76c1ff Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:23:50 +0900 Subject: [PATCH 17/21] feat(network): add empty port list endpoint --- internal/api/network/port.go | 13 ++++++++++ internal/api/network/port_dto.go | 30 +++++++++++++++++++++ internal/api/network/port_test.go | 43 +++++++++++++++++++++++++++++++ internal/api/network/router.go | 1 + internal/app/network/service.go | 4 +++ internal/app/network/types.go | 6 +++++ 6 files changed, 97 insertions(+) create mode 100644 internal/api/network/port.go create mode 100644 internal/api/network/port_dto.go create mode 100644 internal/api/network/port_test.go diff --git a/internal/api/network/port.go b/internal/api/network/port.go new file mode 100644 index 0000000..f9a4986 --- /dev/null +++ b/internal/api/network/port.go @@ -0,0 +1,13 @@ +package network + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listPorts(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, portListResponse{ + Ports: toPortDocuments(h.service.ListPorts()), + }) +} diff --git a/internal/api/network/port_dto.go b/internal/api/network/port_dto.go new file mode 100644 index 0000000..c50926e --- /dev/null +++ b/internal/api/network/port_dto.go @@ -0,0 +1,30 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type portListResponse struct { + Ports []portDocument `json:"ports"` +} + +type portDocument struct { + ID string `json:"id"` + NetworkID string `json:"network_id"` + Name string `json:"name"` +} + +func toPortDocuments(ports []appnetwork.Port) []portDocument { + documents := make([]portDocument, 0, len(ports)) + for _, port := range ports { + documents = append(documents, toPortDocument(port)) + } + + return documents +} + +func toPortDocument(port appnetwork.Port) portDocument { + return portDocument{ + ID: port.ID, + NetworkID: port.NetworkID, + Name: port.Name, + } +} diff --git a/internal/api/network/port_test.go b/internal/api/network/port_test.go new file mode 100644 index 0000000..e8981de --- /dev/null +++ b/internal/api/network/port_test.go @@ -0,0 +1,43 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" + "github.com/stretchr/testify/suite" +) + +type PortSuite struct { + suite.Suite + server *httptest.Server +} + +func TestPortSuite(t *testing.T) { + suite.Run(t, new(PortSuite)) +} + +func (s *PortSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *PortSuite) TearDownTest() { + s.server.Close() +} + +func (s *PortSuite) TestListPorts() { + pages, err := ports.List( + testhelper.ServiceClient(s.server.URL), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := ports.ExtractPorts(pages) + s.Require().NoError(err) + + s.Assert().Empty(list) +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 7fef4f7..a3afe20 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -35,6 +35,7 @@ func (h Handler) Router() http.Handler { router.Post("/subnets", h.createSubnet) router.Get("/subnets/{subnet_id}", h.getSubnet) router.Delete("/subnets/{subnet_id}", h.deleteSubnet) + router.Get("/ports", h.listPorts) return router } diff --git a/internal/app/network/service.go b/internal/app/network/service.go index ded9a8b..7d31ca2 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -191,3 +191,7 @@ func removeString(values []string, target string) []string { return values } + +func (s *Service) ListPorts() []Port { + return []Port{} +} diff --git a/internal/app/network/types.go b/internal/app/network/types.go index b6a4935..429c80a 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -57,3 +57,9 @@ type HostRoute struct { DestinationCIDR string NextHop string } + +type Port struct { + ID string + NetworkID string + Name string +} From fe5b1d365c6b2e3a3a418b23eb2337220e120af4 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:26:20 +0900 Subject: [PATCH 18/21] feat(network): add in-memory port create and list --- internal/api/network/port.go | 14 +++++ internal/api/network/port_dto.go | 97 +++++++++++++++++++++++++++++-- internal/api/network/port_test.go | 56 +++++++++++++++++- internal/api/network/router.go | 1 + internal/app/network/service.go | 45 +++++++++++++- internal/app/network/types.go | 31 +++++++++- 6 files changed, 233 insertions(+), 11 deletions(-) diff --git a/internal/api/network/port.go b/internal/api/network/port.go index f9a4986..8e100ca 100644 --- a/internal/api/network/port.go +++ b/internal/api/network/port.go @@ -1,6 +1,7 @@ package network import ( + "encoding/json" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" @@ -11,3 +12,16 @@ func (h Handler) listPorts(w http.ResponseWriter, _ *http.Request) { Ports: toPortDocuments(h.service.ListPorts()), }) } + +func (h Handler) createPort(w http.ResponseWriter, r *http.Request) { + var request createPortRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + port := h.service.CreatePort(request.createPort()) + respond.JSON(w, http.StatusCreated, portResponse{ + Port: toPortDocument(port), + }) +} diff --git a/internal/api/network/port_dto.go b/internal/api/network/port_dto.go index c50926e..69bf39e 100644 --- a/internal/api/network/port_dto.go +++ b/internal/api/network/port_dto.go @@ -2,14 +2,66 @@ package network import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +type createPortRequest struct { + Port createPortDocument `json:"port"` +} + +type createPortDocument struct { + NetworkID string `json:"network_id"` + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp *bool `json:"admin_state_up"` + FixedIPs []fixedIPDocument `json:"fixed_ips"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` + DeviceID string `json:"device_id"` + DeviceOwner string `json:"device_owner"` +} + +func (r createPortRequest) createPort() appnetwork.CreatePort { + projectID := r.Port.ProjectID + if projectID == "" { + projectID = r.Port.TenantID + } + + return appnetwork.CreatePort{ + NetworkID: r.Port.NetworkID, + Name: r.Port.Name, + Description: r.Port.Description, + AdminStateUp: r.Port.AdminStateUp, + FixedIPs: toAppFixedIPs(r.Port.FixedIPs), + ProjectID: projectID, + DeviceID: r.Port.DeviceID, + DeviceOwner: r.Port.DeviceOwner, + } +} + type portListResponse struct { Ports []portDocument `json:"ports"` } +type portResponse struct { + Port portDocument `json:"port"` +} + type portDocument struct { - ID string `json:"id"` - NetworkID string `json:"network_id"` - Name string `json:"name"` + ID string `json:"id"` + NetworkID string `json:"network_id"` + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp bool `json:"admin_state_up"` + Status string `json:"status"` + MACAddress string `json:"mac_address"` + FixedIPs []fixedIPDocument `json:"fixed_ips"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + DeviceID string `json:"device_id"` + DeviceOwner string `json:"device_owner"` +} + +type fixedIPDocument struct { + SubnetID string `json:"subnet_id,omitempty"` + IPAddress string `json:"ip_address,omitempty"` } func toPortDocuments(ports []appnetwork.Port) []portDocument { @@ -23,8 +75,41 @@ func toPortDocuments(ports []appnetwork.Port) []portDocument { func toPortDocument(port appnetwork.Port) portDocument { return portDocument{ - ID: port.ID, - NetworkID: port.NetworkID, - Name: port.Name, + ID: port.ID, + NetworkID: port.NetworkID, + Name: port.Name, + Description: port.Description, + AdminStateUp: port.AdminStateUp, + Status: port.Status, + MACAddress: port.MACAddress, + FixedIPs: toFixedIPDocuments(port.FixedIPs), + TenantID: port.TenantID, + ProjectID: port.ProjectID, + DeviceID: port.DeviceID, + DeviceOwner: port.DeviceOwner, + } +} + +func toAppFixedIPs(fixedIPs []fixedIPDocument) []appnetwork.FixedIP { + values := make([]appnetwork.FixedIP, 0, len(fixedIPs)) + for _, fixedIP := range fixedIPs { + values = append(values, appnetwork.FixedIP{ + SubnetID: fixedIP.SubnetID, + IPAddress: fixedIP.IPAddress, + }) } + + return values +} + +func toFixedIPDocuments(fixedIPs []appnetwork.FixedIP) []fixedIPDocument { + documents := make([]fixedIPDocument, 0, len(fixedIPs)) + for _, fixedIP := range fixedIPs { + documents = append(documents, fixedIPDocument{ + SubnetID: fixedIP.SubnetID, + IPAddress: fixedIP.IPAddress, + }) + } + + return documents } diff --git a/internal/api/network/port_test.go b/internal/api/network/port_test.go index e8981de..cc5346a 100644 --- a/internal/api/network/port_test.go +++ b/internal/api/network/port_test.go @@ -6,6 +6,7 @@ import ( "github.com/JSYoo5B/SandStack/internal/api/network" "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" "github.com/stretchr/testify/suite" ) @@ -30,6 +31,27 @@ func (s *PortSuite) TearDownTest() { } func (s *PortSuite) TestListPorts() { + list := s.listPorts() + + s.Assert().Empty(list) +} + +func (s *PortSuite) TestCreatePortThenListPorts() { + network := s.createNetwork("private") + + created := s.createPort(network.ID, "private-port") + list := s.listPorts() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal(network.ID, created.NetworkID) + s.Assert().Equal("private-port", created.Name) + s.Assert().Equal("DOWN", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal(network.ID, list[0].NetworkID) +} + +func (s *PortSuite) listPorts() []ports.Port { pages, err := ports.List( testhelper.ServiceClient(s.server.URL), nil, @@ -39,5 +61,37 @@ func (s *PortSuite) TestListPorts() { list, err := ports.ExtractPorts(pages) s.Require().NoError(err) - s.Assert().Empty(list) + return list +} + +func (s *PortSuite) createNetwork(name string) *networks.Network { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + networks.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *PortSuite) createPort(networkID string, name string) *ports.Port { + created, err := ports.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + ports.CreateOpts{ + NetworkID: networkID, + Name: name, + AdminStateUp: boolPtr(true), + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created } diff --git a/internal/api/network/router.go b/internal/api/network/router.go index a3afe20..a313354 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -36,6 +36,7 @@ func (h Handler) Router() http.Handler { router.Get("/subnets/{subnet_id}", h.getSubnet) router.Delete("/subnets/{subnet_id}", h.deleteSubnet) router.Get("/ports", h.listPorts) + router.Post("/ports", h.createPort) return router } diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 7d31ca2..81f869f 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -16,6 +16,8 @@ type Service struct { networks map[string]Network subnetIDs []string subnets map[string]Subnet + portIDs []string + ports map[string]Port } func NewService() *Service { @@ -24,6 +26,8 @@ func NewService() *Service { networks: map[string]Network{}, subnetIDs: []string{}, subnets: map[string]Subnet{}, + portIDs: []string{}, + ports: map[string]Port{}, } } @@ -193,5 +197,44 @@ func removeString(values []string, target string) []string { } func (s *Service) ListPorts() []Port { - return []Port{} + s.mu.RLock() + defer s.mu.RUnlock() + + ports := make([]Port, 0, len(s.portIDs)) + for _, id := range s.portIDs { + ports = append(ports, s.ports[id]) + } + + return ports +} + +func (s *Service) CreatePort(input CreatePort) Port { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + id := "port-" + idgen.RandomHex(16) + port := Port{ + ID: id, + NetworkID: input.NetworkID, + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "DOWN", + MACAddress: "fa:16:3e:" + idgen.RandomHex(6), + FixedIPs: input.FixedIPs, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + DeviceID: input.DeviceID, + DeviceOwner: input.DeviceOwner, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.portIDs = append(s.portIDs, port.ID) + s.ports[port.ID] = port + + return port } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 429c80a..94d1a09 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -59,7 +59,32 @@ type HostRoute struct { } type Port struct { - ID string - NetworkID string - Name string + ID string + NetworkID string + Name string + Description string + AdminStateUp bool + Status string + MACAddress string + FixedIPs []FixedIP + TenantID string + ProjectID string + DeviceID string + DeviceOwner string +} + +type CreatePort struct { + NetworkID string + Name string + Description string + AdminStateUp *bool + FixedIPs []FixedIP + ProjectID string + DeviceID string + DeviceOwner string +} + +type FixedIP struct { + SubnetID string + IPAddress string } From 9c0ebfefb893d4db1d3729ef4bf5badfa94e0e79 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:28:54 +0900 Subject: [PATCH 19/21] feat(network): add port get and delete endpoints --- internal/api/network/port.go | 33 +++++++++++++++++++++++++++++++ internal/api/network/port_test.go | 33 +++++++++++++++++++++++++++++++ internal/api/network/router.go | 2 ++ internal/app/network/service.go | 32 ++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/internal/api/network/port.go b/internal/api/network/port.go index 8e100ca..9e3fce0 100644 --- a/internal/api/network/port.go +++ b/internal/api/network/port.go @@ -2,9 +2,12 @@ package network import ( "encoding/json" + "errors" "net/http" "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" ) func (h Handler) listPorts(w http.ResponseWriter, _ *http.Request) { @@ -25,3 +28,33 @@ func (h Handler) createPort(w http.ResponseWriter, r *http.Request) { Port: toPortDocument(port), }) } + +func (h Handler) getPort(w http.ResponseWriter, r *http.Request) { + port, err := h.service.GetPort(chi.URLParam(r, "port_id")) + if errors.Is(err, appnetwork.ErrPortNotFound) { + respond.Error(w, http.StatusNotFound, "port not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "port lookup failed") + return + } + + respond.JSON(w, http.StatusOK, portResponse{ + Port: toPortDocument(port), + }) +} + +func (h Handler) deletePort(w http.ResponseWriter, r *http.Request) { + err := h.service.DeletePort(chi.URLParam(r, "port_id")) + if errors.Is(err, appnetwork.ErrPortNotFound) { + respond.Error(w, http.StatusNotFound, "port not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "port delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/port_test.go b/internal/api/network/port_test.go index cc5346a..6d58536 100644 --- a/internal/api/network/port_test.go +++ b/internal/api/network/port_test.go @@ -51,6 +51,39 @@ func (s *PortSuite) TestCreatePortThenListPorts() { s.Assert().Equal(network.ID, list[0].NetworkID) } +func (s *PortSuite) TestGetPort() { + network := s.createNetwork("private") + created := s.createPort(network.ID, "private-port") + + found, err := ports.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal(network.ID, found.NetworkID) + s.Assert().Equal("private-port", found.Name) +} + +func (s *PortSuite) TestDeletePort() { + network := s.createNetwork("private") + created := s.createPort(network.ID, "private-port") + + err := ports.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listPorts() + + s.Assert().Empty(list) +} + func (s *PortSuite) listPorts() []ports.Port { pages, err := ports.List( testhelper.ServiceClient(s.server.URL), diff --git a/internal/api/network/router.go b/internal/api/network/router.go index a313354..0bd97a4 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -37,6 +37,8 @@ func (h Handler) Router() http.Handler { router.Delete("/subnets/{subnet_id}", h.deleteSubnet) router.Get("/ports", h.listPorts) router.Post("/ports", h.createPort) + router.Get("/ports/{port_id}", h.getPort) + router.Delete("/ports/{port_id}", h.deletePort) return router } diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 81f869f..eabeedc 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -9,6 +9,7 @@ import ( var ErrNetworkNotFound = errors.New("network not found") var ErrSubnetNotFound = errors.New("subnet not found") +var ErrPortNotFound = errors.New("port not found") type Service struct { mu sync.RWMutex @@ -238,3 +239,34 @@ func (s *Service) CreatePort(input CreatePort) Port { return port } + +func (s *Service) GetPort(id string) (Port, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + port, ok := s.ports[id] + if !ok { + return Port{}, ErrPortNotFound + } + + return port, nil +} + +func (s *Service) DeletePort(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.ports[id]; !ok { + return ErrPortNotFound + } + + delete(s.ports, id) + for index, currentID := range s.portIDs { + if currentID == id { + s.portIDs = append(s.portIDs[:index], s.portIDs[index+1:]...) + break + } + } + + return nil +} From 4a6cb9ded725a400a13352774ece8ee2afd7beeb Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:30:33 +0900 Subject: [PATCH 20/21] feat(volume): add default volume type endpoints --- internal/api/volume/router.go | 2 ++ internal/api/volume/type.go | 32 +++++++++++++++++ internal/api/volume/type_dto.go | 40 ++++++++++++++++++++++ internal/api/volume/type_test.go | 59 ++++++++++++++++++++++++++++++++ internal/app/volume/service.go | 34 ++++++++++++++++-- internal/app/volume/types.go | 8 +++++ 6 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 internal/api/volume/type.go create mode 100644 internal/api/volume/type_dto.go create mode 100644 internal/api/volume/type_test.go diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index 0d0867d..5295c65 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -32,6 +32,8 @@ func (h Handler) Router() http.Handler { router.Post("/{project_id}/volumes", h.createVolume) router.Get("/{project_id}/volumes/{volume_id}", h.getVolume) router.Delete("/{project_id}/volumes/{volume_id}", h.deleteVolume) + router.Get("/{project_id}/types", h.listVolumeTypes) + router.Get("/{project_id}/types/{type_id}", h.getVolumeType) return router } diff --git a/internal/api/volume/type.go b/internal/api/volume/type.go new file mode 100644 index 0000000..500c159 --- /dev/null +++ b/internal/api/volume/type.go @@ -0,0 +1,32 @@ +package volume + +import ( + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listVolumeTypes(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, volumeTypeListResponse{ + VolumeTypes: toVolumeTypeDocuments(h.service.ListVolumeTypes()), + }) +} + +func (h Handler) getVolumeType(w http.ResponseWriter, r *http.Request) { + volumeType, err := h.service.GetVolumeType(chi.URLParam(r, "type_id")) + if errors.Is(err, appvolume.ErrVolumeTypeNotFound) { + respond.Error(w, http.StatusNotFound, "volume type not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "volume type lookup failed") + return + } + + respond.JSON(w, http.StatusOK, volumeTypeResponse{ + VolumeType: toVolumeTypeDocument(volumeType), + }) +} diff --git a/internal/api/volume/type_dto.go b/internal/api/volume/type_dto.go new file mode 100644 index 0000000..4504099 --- /dev/null +++ b/internal/api/volume/type_dto.go @@ -0,0 +1,40 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type volumeTypeListResponse struct { + VolumeTypes []volumeTypeDocument `json:"volume_types"` +} + +type volumeTypeResponse struct { + VolumeType volumeTypeDocument `json:"volume_type"` +} + +type volumeTypeDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ExtraSpecs map[string]string `json:"extra_specs"` + IsPublic bool `json:"is_public"` + PublicAccess bool `json:"os-volume-type-access:is_public"` +} + +func toVolumeTypeDocuments(types []appvolume.VolumeType) []volumeTypeDocument { + documents := make([]volumeTypeDocument, 0, len(types)) + for _, volumeType := range types { + documents = append(documents, toVolumeTypeDocument(volumeType)) + } + + return documents +} + +func toVolumeTypeDocument(volumeType appvolume.VolumeType) volumeTypeDocument { + return volumeTypeDocument{ + ID: volumeType.ID, + Name: volumeType.Name, + Description: volumeType.Description, + ExtraSpecs: volumeType.ExtraSpecs, + IsPublic: volumeType.IsPublic, + PublicAccess: volumeType.IsPublic, + } +} diff --git a/internal/api/volume/type_test.go b/internal/api/volume/type_test.go new file mode 100644 index 0000000..3b401eb --- /dev/null +++ b/internal/api/volume/type_test.go @@ -0,0 +1,59 @@ +package volume_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/volume" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumetypes" + "github.com/stretchr/testify/suite" +) + +type VolumeTypeSuite struct { + suite.Suite + server *httptest.Server +} + +func TestVolumeTypeSuite(t *testing.T) { + suite.Run(t, new(VolumeTypeSuite)) +} + +func (s *VolumeTypeSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *VolumeTypeSuite) TearDownTest() { + s.server.Close() +} + +func (s *VolumeTypeSuite) TestListVolumeTypes() { + pages, err := volumetypes.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := volumetypes.ExtractVolumeTypes(pages) + s.Require().NoError(err) + + s.Require().Len(list, 1) + s.Assert().Equal("default", list[0].ID) + s.Assert().Equal("__DEFAULT__", list[0].Name) + s.Assert().Equal(true, list[0].IsPublic) +} + +func (s *VolumeTypeSuite) TestGetVolumeType() { + volumeType, err := volumetypes.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + "default", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(volumeType) + + s.Assert().Equal("default", volumeType.ID) + s.Assert().Equal("__DEFAULT__", volumeType.Name) +} diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 3c8b815..2ac24a2 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -11,17 +11,28 @@ import ( const timestampFormat = "2006-01-02T15:04:05.999999" var ErrVolumeNotFound = errors.New("volume not found") +var ErrVolumeTypeNotFound = errors.New("volume type not found") type Service struct { - mu sync.RWMutex - ids []string - volumes map[string]Volume + mu sync.RWMutex + ids []string + volumes map[string]Volume + volumeTypes []VolumeType } func NewService() *Service { return &Service{ ids: []string{}, volumes: map[string]Volume{}, + volumeTypes: []VolumeType{ + { + ID: "default", + Name: "__DEFAULT__", + Description: "Default test volume type", + ExtraSpecs: map[string]string{}, + IsPublic: true, + }, + }, } } @@ -91,3 +102,20 @@ func (s *Service) Delete(id string) error { return nil } + +func (s *Service) ListVolumeTypes() []VolumeType { + volumeTypes := make([]VolumeType, 0, len(s.volumeTypes)) + volumeTypes = append(volumeTypes, s.volumeTypes...) + + return volumeTypes +} + +func (s *Service) GetVolumeType(id string) (VolumeType, error) { + for _, volumeType := range s.volumeTypes { + if volumeType.ID == id { + return volumeType, nil + } + } + + return VolumeType{}, ErrVolumeTypeNotFound +} diff --git a/internal/app/volume/types.go b/internal/app/volume/types.go index 9e21223..33403a1 100644 --- a/internal/app/volume/types.go +++ b/internal/app/volume/types.go @@ -22,3 +22,11 @@ type Volume struct { Encrypted bool Multiattach bool } + +type VolumeType struct { + ID string + Name string + Description string + ExtraSpecs map[string]string + IsPublic bool +} From 791c424ffbdd6892f0488683e4e477f93e4330a6 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:31:36 +0900 Subject: [PATCH 21/21] test(api): cover phase 2 core resource flow --- internal/api/core_resource_flow_test.go | 120 ++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 internal/api/core_resource_flow_test.go diff --git a/internal/api/core_resource_flow_test.go b/internal/api/core_resource_flow_test.go new file mode 100644 index 0000000..40b5ede --- /dev/null +++ b/internal/api/core_resource_flow_test.go @@ -0,0 +1,120 @@ +package api_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "github.com/stretchr/testify/suite" +) + +type CoreResourceFlowSuite struct { + suite.Suite + server *httptest.Server +} + +func TestCoreResourceFlowSuite(t *testing.T) { + suite.Run(t, new(CoreResourceFlowSuite)) +} + +func (s *CoreResourceFlowSuite) SetupTest() { + s.server = httptest.NewServer(api.NewRouter(testhelper.DefaultConfig())) +} + +func (s *CoreResourceFlowSuite) TearDownTest() { + s.server.Close() +} + +func (s *CoreResourceFlowSuite) TestCreateCoreResources() { + image := s.createImage("ubuntu") + network := s.createNetwork("private") + server := s.createServer("web", image.ID, network.ID) + volume := s.createVolume("database") + + s.Assert().NotEmpty(image.ID) + s.Assert().Equal("ubuntu", image.Name) + s.Assert().NotEmpty(network.ID) + s.Assert().Equal("private", network.Name) + s.Assert().NotEmpty(server.ID) + s.Assert().Equal("web", server.Name) + s.Assert().NotEmpty(volume.ID) + s.Assert().Equal("database", volume.Name) +} + +func (s *CoreResourceFlowSuite) createImage(name string) *images.Image { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/image/v2"), + images.CreateOpts{ + Name: name, + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *CoreResourceFlowSuite) createNetwork( + name string, +) *networks.Network { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/network/v2.0"), + networks.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *CoreResourceFlowSuite) createServer( + name string, + imageID string, + networkID string, +) *servers.Server { + created, err := servers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/compute/v2.1/demo"), + servers.CreateOpts{ + Name: name, + ImageRef: imageID, + FlavorRef: "1", + Networks: []servers.Network{ + {UUID: networkID}, + }, + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *CoreResourceFlowSuite) createVolume(name string) *volumes.Volume { + created, err := volumes.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/volume/v3/demo"), + volumes.CreateOpts{ + Size: 1, + Name: name, + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +}