Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ func NewRouter(cfg config.Config) http.Handler {
)
volumeService := appvolume.NewServiceWithRuntime(
storevolume.NewMemoryRepository(),
storevolume.NewMemorySnapshotRepository(),
storevolume.NewMemoryTransferRepository(),
storevolume.NewMemoryBackupRepository(),
storevolume.NewMemoryAttachmentRepository(),
storevolume.NewMemoryQuotaRepository(),
clock.Wall(),
idgen.Random(),
)
Expand Down
116 changes: 116 additions & 0 deletions internal/api/volume/action.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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) actionVolume(w http.ResponseWriter, r *http.Request) {
var body map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
return
}

volumeID := chi.URLParam(r, "volume_id")
for action, payload := range body {
if h.handleVolumeAction(w, volumeID, action, payload) {
return
}
}

respond.Error(w, http.StatusBadRequest, "unsupported volume action")
}

func (h Handler) handleVolumeAction(
w http.ResponseWriter,
volumeID string,
action string,
payload json.RawMessage,
) bool {
switch action {
case "os-attach":
var request attachVolumeRequest
if !decodeVolumeAction(w, payload, &request) {
return true
}
h.respondVolumeAction(w, h.service.Attach(volumeID, appvolume.AttachVolume{
InstanceUUID: request.InstanceUUID,
HostName: request.HostName,
MountPoint: request.MountPoint,
Mode: request.Mode,
}))
return true
case "os-begin_detaching":
h.respondVolumeAction(w, h.service.BeginDetaching(volumeID))
return true
case "os-detach":
var request detachVolumeRequest
if !decodeVolumeAction(w, payload, &request) {
return true
}
h.respondVolumeAction(w, h.service.Detach(volumeID, appvolume.DetachVolume{
AttachmentID: request.AttachmentID,
}))
return true
case "os-reserve":
h.respondVolumeAction(w, h.service.Reserve(volumeID))
return true
case "os-unreserve":
h.respondVolumeAction(w, h.service.Unreserve(volumeID))
return true
case "os-extend":
var request extendVolumeRequest
if !decodeVolumeAction(w, payload, &request) {
return true
}
h.respondVolumeAction(w, h.service.ExtendSize(volumeID, request.NewSize))
return true
case "os-reset_status":
var request resetVolumeStatusRequest
if !decodeVolumeAction(w, payload, &request) {
return true
}
h.respondVolumeAction(w, h.service.ResetStatus(
volumeID,
appvolume.ResetVolumeStatus{
Status: request.Status,
AttachStatus: request.AttachStatus,
},
))
return true
default:
return false
}
}

func (h Handler) respondVolumeAction(w http.ResponseWriter, err error) {
if errors.Is(err, appvolume.ErrVolumeNotFound) {
respond.Error(w, http.StatusNotFound, "volume not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "volume action failed")
return
}

w.WriteHeader(http.StatusAccepted)
}

func decodeVolumeAction(
w http.ResponseWriter,
payload json.RawMessage,
target any,
) bool {
if err := json.Unmarshal(payload, target); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid volume action body")
return false
}

return true
}
21 changes: 21 additions & 0 deletions internal/api/volume/action_dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package volume

type attachVolumeRequest struct {
MountPoint string `json:"mountpoint"`
InstanceUUID string `json:"instance_uuid"`
HostName string `json:"host_name"`
Mode string `json:"mode"`
}

type detachVolumeRequest struct {
AttachmentID string `json:"attachment_id"`
}

type extendVolumeRequest struct {
NewSize int `json:"new_size"`
}

type resetVolumeStatusRequest struct {
Status string `json:"status"`
AttachStatus string `json:"attach_status"`
}
129 changes: 129 additions & 0 deletions internal/api/volume/action_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package volume_test

import (
"context"
"net/http/httptest"
"testing"

"github.com/JSYoo5B/SandStack/internal/api/volume"
"github.com/JSYoo5B/SandStack/internal/testhelper"
"github.com/gophercloud/gophercloud/v2"
"github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes"
"github.com/stretchr/testify/suite"
)

type VolumeActionSuite struct {
suite.Suite
server *httptest.Server
}

func TestVolumeActionSuite(t *testing.T) {
suite.Run(t, new(VolumeActionSuite))
}

func (s *VolumeActionSuite) SetupTest() {
s.server = httptest.NewServer(
volume.NewRouter(testhelper.DefaultConfig()),
)
}

func (s *VolumeActionSuite) TearDownTest() {
s.server.Close()
}

func (s *VolumeActionSuite) TestAttachAndDetachVolume() {
client := testhelper.ServiceClient(s.server.URL + "/demo")
created := s.createVolume(client)

err := volumes.Attach(
context.Background(),
client,
created.ID,
volumes.AttachOpts{
InstanceUUID: "server-1",
Mode: volumes.ReadWrite,
},
).ExtractErr()
s.Require().NoError(err)

attached, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal("in-use", attached.Status)

err = volumes.Detach(
context.Background(),
client,
created.ID,
volumes.DetachOpts{},
).ExtractErr()
s.Require().NoError(err)

detached, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal("available", detached.Status)
}

func (s *VolumeActionSuite) TestReserveAndUnreserveVolume() {
client := testhelper.ServiceClient(s.server.URL + "/demo")
created := s.createVolume(client)

err := volumes.Reserve(context.Background(), client, created.ID).ExtractErr()
s.Require().NoError(err)

reserved, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal("reserved", reserved.Status)

err = volumes.Unreserve(context.Background(), client, created.ID).ExtractErr()
s.Require().NoError(err)

unreserved, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal("available", unreserved.Status)
}

func (s *VolumeActionSuite) TestExtendAndResetStatusVolume() {
client := testhelper.ServiceClient(s.server.URL + "/demo")
created := s.createVolume(client)

err := volumes.ExtendSize(
context.Background(),
client,
created.ID,
volumes.ExtendSizeOpts{NewSize: 2},
).ExtractErr()
s.Require().NoError(err)

extended, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal(2, extended.Size)

err = volumes.ResetStatus(
context.Background(),
client,
created.ID,
volumes.ResetStatusOpts{Status: "error"},
).ExtractErr()
s.Require().NoError(err)

reset, err := volumes.Get(context.Background(), client, created.ID).Extract()
s.Require().NoError(err)
s.Assert().Equal("error", reset.Status)
}

func (s *VolumeActionSuite) createVolume(
client *gophercloud.ServiceClient,
) *volumes.Volume {
created, err := volumes.Create(
context.Background(),
client,
volumes.CreateOpts{
Size: 1,
Name: "database",
},
nil,
).Extract()
s.Require().NoError(err)

return created
}
108 changes: 108 additions & 0 deletions internal/api/volume/attachment.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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) listAttachments(w http.ResponseWriter, r *http.Request) {
respond.JSON(w, http.StatusOK, attachmentListResponse{
Attachments: toAttachmentDocuments(h.service.ListAttachments()),
})
}

func (h Handler) createAttachment(w http.ResponseWriter, r *http.Request) {
var request createAttachmentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
return
}

attachment := h.service.CreateAttachment(request.createAttachment())

respond.JSON(w, http.StatusAccepted, attachmentResponse{
Attachment: toAttachmentDocument(attachment),
})
}

func (h Handler) getAttachment(w http.ResponseWriter, r *http.Request) {
attachment, err := h.service.GetAttachment(
chi.URLParam(r, "attachment_id"),
)
if errors.Is(err, appvolume.ErrAttachmentNotFound) {
respond.Error(w, http.StatusNotFound, "attachment not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "attachment lookup failed")
return
}

respond.JSON(w, http.StatusOK, attachmentResponse{
Attachment: toAttachmentDocument(attachment),
})
}

func (h Handler) updateAttachment(w http.ResponseWriter, r *http.Request) {
var request updateAttachmentRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
return
}

attachment, err := h.service.UpdateAttachment(
chi.URLParam(r, "attachment_id"),
request.updateAttachment(),
)
if errors.Is(err, appvolume.ErrAttachmentNotFound) {
respond.Error(w, http.StatusNotFound, "attachment not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "attachment update failed")
return
}

respond.JSON(w, http.StatusOK, attachmentResponse{
Attachment: toAttachmentDocument(attachment),
})
}

func (h Handler) deleteAttachment(w http.ResponseWriter, r *http.Request) {
err := h.service.DeleteAttachment(chi.URLParam(r, "attachment_id"))
if errors.Is(err, appvolume.ErrAttachmentNotFound) {
respond.Error(w, http.StatusNotFound, "attachment not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "attachment delete failed")
return
}

respond.JSON(w, http.StatusOK, map[string]any{})
}

func (h Handler) actionAttachment(w http.ResponseWriter, r *http.Request) {
var request attachmentActionRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON request body")
return
}

err := h.service.CompleteAttachment(chi.URLParam(r, "attachment_id"))
if errors.Is(err, appvolume.ErrAttachmentNotFound) {
respond.Error(w, http.StatusNotFound, "attachment not found")
return
}
if err != nil {
respond.Error(w, http.StatusInternalServerError, "attachment action failed")
return
}

w.WriteHeader(http.StatusNoContent)
}
Loading
Loading