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..4101a71 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,24 @@ 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) + 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 new file mode 100644 index 0000000..a175cf2 --- /dev/null +++ b/internal/api/compute/server.go @@ -0,0 +1,60 @@ +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) { + respond.JSON(w, http.StatusOK, serverListResponse{ + 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), + }) +} + +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_dto.go b/internal/api/compute/server_dto.go new file mode 100644 index 0000000..ae90104 --- /dev/null +++ b/internal/api/compute/server_dto.go @@ -0,0 +1,79 @@ +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"` + 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 { + 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, + 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 new file mode 100644 index 0000000..63b68b5 --- /dev/null +++ b/internal/api/compute/server_test.go @@ -0,0 +1,130 @@ +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) +} + +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) 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(), + 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/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 +} diff --git a/internal/api/image/image.go b/internal/api/image/image.go new file mode 100644 index 0000000..364f23b --- /dev/null +++ b/internal/api/image/image.go @@ -0,0 +1,56 @@ +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) { + respond.JSON(w, http.StatusOK, imageListResponse{ + 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)) +} + +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_dto.go b/internal/api/image/image_dto.go new file mode 100644 index 0000000..72722f3 --- /dev/null +++ b/internal/api/image/image_dto.go @@ -0,0 +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"` + 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 new file mode 100644 index 0000000..b6b5ed5 --- /dev/null +++ b/internal/api/image/image_test.go @@ -0,0 +1,108 @@ +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() { + list := s.listImages() + + s.Assert().Empty(list) +} + +func (s *ImageSuite) TestCreateImageThenListImages() { + created := s.createImage("ubuntu") + + 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) 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), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := images.ExtractImages(pages) + s.Require().NoError(err) + + 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 9aca65a..e944a44 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,12 +18,19 @@ 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) + router.Get("/images/{image_id}", h.getImage) + router.Delete("/images/{image_id}", h.deleteImage) return router } diff --git a/internal/api/network/network.go b/internal/api/network/network.go new file mode 100644 index 0000000..0166a3e --- /dev/null +++ b/internal/api/network/network.go @@ -0,0 +1,60 @@ +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) { + respond.JSON(w, http.StatusOK, networkListResponse{ + 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), + }) +} + +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_dto.go b/internal/api/network/network_dto.go new file mode 100644 index 0000000..229b9ae --- /dev/null +++ b/internal/api/network/network_dto.go @@ -0,0 +1,72 @@ +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"` + 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"` +} + +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 new file mode 100644 index 0000000..9e348fc --- /dev/null +++ b/internal/api/network/network_test.go @@ -0,0 +1,107 @@ +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() { + list := s.listNetworks() + + s.Assert().Empty(list) +} + +func (s *NetworkSuite) TestCreateNetworkThenListNetworks() { + created := s.createNetwork("private") + + 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) 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), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := networks.ExtractNetworks(pages) + s.Require().NoError(err) + + 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/port.go b/internal/api/network/port.go new file mode 100644 index 0000000..9e3fce0 --- /dev/null +++ b/internal/api/network/port.go @@ -0,0 +1,60 @@ +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) { + respond.JSON(w, http.StatusOK, portListResponse{ + 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), + }) +} + +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_dto.go b/internal/api/network/port_dto.go new file mode 100644 index 0000000..69bf39e --- /dev/null +++ b/internal/api/network/port_dto.go @@ -0,0 +1,115 @@ +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"` + 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 { + 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, + 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 new file mode 100644 index 0000000..6d58536 --- /dev/null +++ b/internal/api/network/port_test.go @@ -0,0 +1,130 @@ +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/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() { + 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) 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), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := ports.ExtractPorts(pages) + s.Require().NoError(err) + + 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 b82902a..0bd97a4 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,12 +18,27 @@ 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) + router.Get("/networks/{network_id}", h.getNetwork) + 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) + 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/api/network/subnet.go b/internal/api/network/subnet.go new file mode 100644 index 0000000..22583e3 --- /dev/null +++ b/internal/api/network/subnet.go @@ -0,0 +1,60 @@ +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) { + respond.JSON(w, http.StatusOK, subnetListResponse{ + 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), + }) +} + +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_dto.go b/internal/api/network/subnet_dto.go new file mode 100644 index 0000000..71beb70 --- /dev/null +++ b/internal/api/network/subnet_dto.go @@ -0,0 +1,131 @@ +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"` + 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 { + 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, + 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 new file mode 100644 index 0000000..fae6206 --- /dev/null +++ b/internal/api/network/subnet_test.go @@ -0,0 +1,140 @@ +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" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "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() { + 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) 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), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := subnets.ExtractSubnets(pages) + s.Require().NoError(err) + + 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/api/volume/router.go b/internal/api/volume/router.go index 64f27e3..5295c65 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,13 +18,22 @@ 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 { router := chi.NewRouter() 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) + 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/api/volume/volume.go b/internal/api/volume/volume.go new file mode 100644 index 0000000..450fc31 --- /dev/null +++ b/internal/api/volume/volume.go @@ -0,0 +1,60 @@ +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) { + respond.JSON(w, http.StatusOK, volumeListResponse{ + 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), + }) +} + +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_dto.go b/internal/api/volume/volume_dto.go new file mode 100644 index 0000000..4e5a0dc --- /dev/null +++ b/internal/api/volume/volume_dto.go @@ -0,0 +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 new file mode 100644 index 0000000..de13521 --- /dev/null +++ b/internal/api/volume/volume_test.go @@ -0,0 +1,110 @@ +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() { + list := s.listVolumes() + + s.Assert().Empty(list) +} + +func (s *VolumeSuite) TestCreateVolumeThenListVolumes() { + created := s.createVolume("database") + + 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) 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"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := volumes.ExtractVolumes(pages) + s.Require().NoError(err) + + 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/compute/service.go b/internal/app/compute/service.go new file mode 100644 index 0000000..8438cdb --- /dev/null +++ b/internal/app/compute/service.go @@ -0,0 +1,126 @@ +package compute + +import ( + "errors" + "sync" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ErrFlavorNotFound = errors.New("flavor not found") +var ErrServerNotFound = errors.New("server not found") + +type Service struct { + flavors []Flavor + mu sync.RWMutex + ids []string + servers map[string]Server +} + +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{}, + }, + }, + ids: []string{}, + servers: map[string]Server{}, + } +} + +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 +} + +func (s *Service) ListServers() []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 +} + +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 +} diff --git a/internal/app/compute/types.go b/internal/app/compute/types.go new file mode 100644 index 0000000..19e4b97 --- /dev/null +++ b/internal/app/compute/types.go @@ -0,0 +1,36 @@ +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 +} + +type Server struct { + 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 +} diff --git a/internal/app/image/service.go b/internal/app/image/service.go new file mode 100644 index 0000000..1b8c375 --- /dev/null +++ b/internal/app/image/service.go @@ -0,0 +1,93 @@ +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 + 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 +} + +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 +} 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 +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go new file mode 100644 index 0000000..eabeedc --- /dev/null +++ b/internal/app/network/service.go @@ -0,0 +1,272 @@ +package network + +import ( + "errors" + "sync" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +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 + ids []string + networks map[string]Network + subnetIDs []string + subnets map[string]Subnet + portIDs []string + ports map[string]Port +} + +func NewService() *Service { + return &Service{ + ids: []string{}, + networks: map[string]Network{}, + subnetIDs: []string{}, + subnets: map[string]Subnet{}, + portIDs: []string{}, + ports: map[string]Port{}, + } +} + +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 +} + +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 +} + +func (s *Service) ListSubnets() []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 +} + +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 +} + +func (s *Service) ListPorts() []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 +} + +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 +} diff --git a/internal/app/network/types.go b/internal/app/network/types.go new file mode 100644 index 0000000..94d1a09 --- /dev/null +++ b/internal/app/network/types.go @@ -0,0 +1,90 @@ +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 +} + +type Subnet struct { + 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 +} + +type Port struct { + 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 +} diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go new file mode 100644 index 0000000..2ac24a2 --- /dev/null +++ b/internal/app/volume/service.go @@ -0,0 +1,121 @@ +package volume + +import ( + "errors" + "sync" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +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 + 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, + }, + }, + } +} + +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 +} + +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 +} + +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 new file mode 100644 index 0000000..33403a1 --- /dev/null +++ b/internal/app/volume/types.go @@ -0,0 +1,32 @@ +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 +} + +type VolumeType struct { + ID string + Name string + Description string + ExtraSpecs map[string]string + IsPublic bool +}