diff --git a/internal/api/router.go b/internal/api/router.go index 136bfa7..86af9b2 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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(), ) diff --git a/internal/api/volume/action.go b/internal/api/volume/action.go new file mode 100644 index 0000000..5579921 --- /dev/null +++ b/internal/api/volume/action.go @@ -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 +} diff --git a/internal/api/volume/action_dto.go b/internal/api/volume/action_dto.go new file mode 100644 index 0000000..c23875c --- /dev/null +++ b/internal/api/volume/action_dto.go @@ -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"` +} diff --git a/internal/api/volume/action_test.go b/internal/api/volume/action_test.go new file mode 100644 index 0000000..86d1c33 --- /dev/null +++ b/internal/api/volume/action_test.go @@ -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 +} diff --git a/internal/api/volume/attachment.go b/internal/api/volume/attachment.go new file mode 100644 index 0000000..c7f8949 --- /dev/null +++ b/internal/api/volume/attachment.go @@ -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) +} diff --git a/internal/api/volume/attachment_dto.go b/internal/api/volume/attachment_dto.go new file mode 100644 index 0000000..8296cec --- /dev/null +++ b/internal/api/volume/attachment_dto.go @@ -0,0 +1,82 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type createAttachmentRequest struct { + Attachment struct { + VolumeUUID string `json:"volume_uuid"` + InstanceUUID string `json:"instance_uuid"` + Connector map[string]any `json:"connector"` + Mode string `json:"mode"` + } `json:"attachment"` +} + +type updateAttachmentRequest struct { + Attachment struct { + Connector map[string]any `json:"connector"` + } `json:"attachment"` +} + +type attachmentActionRequest struct { + Complete any `json:"os-complete"` +} + +type attachmentListResponse struct { + Attachments []attachmentDocument `json:"attachments"` +} + +type attachmentResponse struct { + Attachment attachmentDocument `json:"attachment"` +} + +type attachmentDocument struct { + ID string `json:"id"` + VolumeID string `json:"volume_id"` + Instance string `json:"instance"` + AttachedAt string `json:"attached_at"` + DetachedAt string `json:"detached_at,omitempty"` + Status string `json:"status"` + AttachMode string `json:"attach_mode"` + ConnectionInfo map[string]any `json:"connection_info"` +} + +func (r createAttachmentRequest) createAttachment() appvolume.CreateAttachment { + return appvolume.CreateAttachment{ + VolumeID: r.Attachment.VolumeUUID, + InstanceID: r.Attachment.InstanceUUID, + Connector: r.Attachment.Connector, + Mode: r.Attachment.Mode, + } +} + +func (r updateAttachmentRequest) updateAttachment() appvolume.UpdateAttachment { + return appvolume.UpdateAttachment{ + Connector: r.Attachment.Connector, + } +} + +func toAttachmentDocuments( + attachments []appvolume.Attachment, +) []attachmentDocument { + documents := make([]attachmentDocument, 0, len(attachments)) + for _, attachment := range attachments { + documents = append(documents, toAttachmentDocument(attachment)) + } + + return documents +} + +func toAttachmentDocument( + attachment appvolume.Attachment, +) attachmentDocument { + return attachmentDocument{ + ID: attachment.ID, + VolumeID: attachment.VolumeID, + Instance: attachment.Instance, + AttachedAt: attachment.AttachedAt, + DetachedAt: attachment.DetachedAt, + Status: attachment.Status, + AttachMode: attachment.AttachMode, + ConnectionInfo: attachment.ConnectionInfo, + } +} diff --git a/internal/api/volume/attachment_test.go b/internal/api/volume/attachment_test.go new file mode 100644 index 0000000..9042ed6 --- /dev/null +++ b/internal/api/volume/attachment_test.go @@ -0,0 +1,152 @@ +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/openstack/blockstorage/v3/attachments" + "github.com/stretchr/testify/suite" +) + +type AttachmentSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAttachmentSuite(t *testing.T) { + suite.Run(t, new(AttachmentSuite)) +} + +func (s *AttachmentSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AttachmentSuite) TearDownTest() { + s.server.Close() +} + +func (s *AttachmentSuite) TestCreateAndGetAttachment() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + created, err := attachments.Create( + context.Background(), + client, + attachments.CreateOpts{ + VolumeUUID: "volume-1", + InstanceUUID: "server-1", + Mode: "rw", + }, + ).Extract() + s.Require().NoError(err) + + found, err := attachments.Get( + context.Background(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("volume-1", found.VolumeID) + s.Assert().Equal("server-1", found.Instance) + s.Assert().Equal("rw", found.AttachMode) +} + +func (s *AttachmentSuite) TestListAttachments() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + _, err := attachments.Create( + context.Background(), + client, + attachments.CreateOpts{ + VolumeUUID: "volume-1", + InstanceUUID: "server-1", + }, + ).Extract() + s.Require().NoError(err) + + pages, err := attachments.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + found, err := attachments.ExtractAttachments(pages) + s.Require().NoError(err) + + s.Require().Len(found, 1) + s.Assert().Equal("volume-1", found[0].VolumeID) +} + +func (s *AttachmentSuite) TestUpdateAndCompleteAttachment() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + created, err := attachments.Create( + context.Background(), + client, + attachments.CreateOpts{ + VolumeUUID: "volume-1", + InstanceUUID: "server-1", + }, + ).Extract() + s.Require().NoError(err) + + updated, err := attachments.Update( + context.Background(), + client, + created.ID, + attachments.UpdateOpts{ + Connector: map[string]any{ + "host": "compute-1", + }, + }, + ).Extract() + s.Require().NoError(err) + + err = attachments.Complete( + context.Background(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + found, err := attachments.Get( + context.Background(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("attaching", updated.Status) + s.Assert().Equal("attached", found.Status) +} + +func (s *AttachmentSuite) TestDeleteAttachment() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + created, err := attachments.Create( + context.Background(), + client, + attachments.CreateOpts{ + VolumeUUID: "volume-1", + InstanceUUID: "server-1", + }, + ).Extract() + s.Require().NoError(err) + + err = attachments.Delete( + context.Background(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + pages, err := attachments.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + found, err := attachments.ExtractAttachments(pages) + s.Require().NoError(err) + + s.Assert().Empty(found) +} diff --git a/internal/api/volume/availability_zone.go b/internal/api/volume/availability_zone.go new file mode 100644 index 0000000..1d4eae1 --- /dev/null +++ b/internal/api/volume/availability_zone.go @@ -0,0 +1,15 @@ +package volume + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listAvailabilityZones(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, availabilityZoneListResponse{ + AvailabilityZoneInfo: toAvailabilityZoneDocuments( + h.service.ListAvailabilityZones(), + ), + }) +} diff --git a/internal/api/volume/availability_zone_dto.go b/internal/api/volume/availability_zone_dto.go new file mode 100644 index 0000000..12151e3 --- /dev/null +++ b/internal/api/volume/availability_zone_dto.go @@ -0,0 +1,38 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type availabilityZoneListResponse struct { + AvailabilityZoneInfo []availabilityZoneDocument `json:"availabilityZoneInfo"` +} + +type availabilityZoneDocument struct { + ZoneName string `json:"zoneName"` + ZoneState availabilityZoneState `json:"zoneState"` +} + +type availabilityZoneState struct { + Available bool `json:"available"` +} + +func toAvailabilityZoneDocuments( + zones []appvolume.AvailabilityZone, +) []availabilityZoneDocument { + documents := make([]availabilityZoneDocument, 0, len(zones)) + for _, zone := range zones { + documents = append(documents, toAvailabilityZoneDocument(zone)) + } + + return documents +} + +func toAvailabilityZoneDocument( + zone appvolume.AvailabilityZone, +) availabilityZoneDocument { + return availabilityZoneDocument{ + ZoneName: zone.Name, + ZoneState: availabilityZoneState{ + Available: zone.Available, + }, + } +} diff --git a/internal/api/volume/availability_zone_test.go b/internal/api/volume/availability_zone_test.go new file mode 100644 index 0000000..144cdd9 --- /dev/null +++ b/internal/api/volume/availability_zone_test.go @@ -0,0 +1,44 @@ +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/availabilityzones" + "github.com/stretchr/testify/suite" +) + +type AvailabilityZoneSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAvailabilityZoneSuite(t *testing.T) { + suite.Run(t, new(AvailabilityZoneSuite)) +} + +func (s *AvailabilityZoneSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AvailabilityZoneSuite) TearDownTest() { + s.server.Close() +} + +func (s *AvailabilityZoneSuite) TestListAvailabilityZones() { + pages, err := availabilityzones.List( + testhelper.ServiceClient(s.server.URL + "/demo"), + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := availabilityzones.ExtractAvailabilityZones(pages) + s.Require().NoError(err) + + s.Require().Len(list, 1) + s.Assert().Equal("nova", list[0].ZoneName) + s.Assert().True(list[0].ZoneState.Available) +} diff --git a/internal/api/volume/backup.go b/internal/api/volume/backup.go new file mode 100644 index 0000000..4d4ef25 --- /dev/null +++ b/internal/api/volume/backup.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) listBackups(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, backupListResponse{ + Backups: toBackupDocuments(h.service.ListBackups()), + }) +} + +func (h Handler) createBackup(w http.ResponseWriter, r *http.Request) { + var request createBackupRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + backup := h.service.CreateBackup(request.createBackup()) + respond.JSON(w, http.StatusAccepted, backupResponse{ + Backup: toBackupDocument(backup), + }) +} + +func (h Handler) getBackup(w http.ResponseWriter, r *http.Request) { + backup, err := h.service.GetBackup(chi.URLParam(r, "backup_id")) + if errors.Is(err, appvolume.ErrBackupNotFound) { + respond.Error(w, http.StatusNotFound, "backup not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "backup lookup failed") + return + } + + respond.JSON(w, http.StatusOK, backupResponse{ + Backup: toBackupDocument(backup), + }) +} + +func (h Handler) deleteBackup(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteBackup(chi.URLParam(r, "backup_id")) + if errors.Is(err, appvolume.ErrBackupNotFound) { + respond.Error(w, http.StatusNotFound, "backup not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "backup delete failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/api/volume/backup_dto.go b/internal/api/volume/backup_dto.go new file mode 100644 index 0000000..6d9e4c0 --- /dev/null +++ b/internal/api/volume/backup_dto.go @@ -0,0 +1,97 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type createBackupRequest struct { + Backup createBackupDocument `json:"backup"` +} + +type createBackupDocument struct { + Name string `json:"name"` + Description string `json:"description"` + VolumeID string `json:"volume_id"` + Force bool `json:"force"` + Metadata map[string]string `json:"metadata"` + Container string `json:"container"` + Incremental bool `json:"incremental"` + SnapshotID string `json:"snapshot_id"` + AvailabilityZone string `json:"availability_zone"` +} + +func (r createBackupRequest) createBackup() appvolume.CreateBackup { + return appvolume.CreateBackup{ + Name: r.Backup.Name, + Description: r.Backup.Description, + VolumeID: r.Backup.VolumeID, + Force: r.Backup.Force, + Metadata: r.Backup.Metadata, + Container: r.Backup.Container, + Incremental: r.Backup.Incremental, + SnapshotID: r.Backup.SnapshotID, + AvailabilityZone: r.Backup.AvailabilityZone, + } +} + +type backupListResponse struct { + Backups []backupDocument `json:"backups"` +} + +type backupResponse struct { + Backup backupDocument `json:"backup"` +} + +type backupDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + VolumeID string `json:"volume_id"` + SnapshotID string `json:"snapshot_id"` + Status string `json:"status"` + Size int `json:"size"` + ObjectCount int `json:"object_count"` + Container string `json:"container"` + HasDependentBackups bool `json:"has_dependent_backups"` + FailReason string `json:"fail_reason"` + IsIncremental bool `json:"is_incremental"` + ProjectID string `json:"os-backup-project-attr:project_id"` + Metadata *map[string]string `json:"metadata"` + AvailabilityZone *string `json:"availability_zone"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DataTimestamp string `json:"data_timestamp"` +} + +func toBackupDocuments(backups []appvolume.Backup) []backupDocument { + documents := make([]backupDocument, 0, len(backups)) + for _, backup := range backups { + documents = append(documents, toBackupDocument(backup)) + } + + return documents +} + +func toBackupDocument(backup appvolume.Backup) backupDocument { + metadata := backup.Metadata + availabilityZone := backup.AvailabilityZone + + return backupDocument{ + ID: backup.ID, + Name: backup.Name, + Description: backup.Description, + VolumeID: backup.VolumeID, + SnapshotID: backup.SnapshotID, + Status: backup.Status, + Size: backup.Size, + ObjectCount: backup.ObjectCount, + Container: backup.Container, + HasDependentBackups: backup.HasDependentBackups, + FailReason: backup.FailReason, + IsIncremental: backup.IsIncremental, + ProjectID: backup.ProjectID, + Metadata: &metadata, + AvailabilityZone: &availabilityZone, + CreatedAt: backup.CreatedAt, + UpdatedAt: backup.UpdatedAt, + DataTimestamp: backup.DataTimestamp, + } +} diff --git a/internal/api/volume/backup_test.go b/internal/api/volume/backup_test.go new file mode 100644 index 0000000..a7135e4 --- /dev/null +++ b/internal/api/volume/backup_test.go @@ -0,0 +1,124 @@ +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/backups" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type BackupSuite struct { + suite.Suite + server *httptest.Server +} + +func TestBackupSuite(t *testing.T) { + suite.Run(t, new(BackupSuite)) +} + +func (s *BackupSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *BackupSuite) TearDownTest() { + s.server.Close() +} + +func (s *BackupSuite) TestCreateBackupThenListBackups() { + volume := s.createVolume("database") + + created := s.createBackup(volume.ID, "database-backup") + list := s.listBackups() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal(volume.ID, created.VolumeID) + s.Assert().Equal("database-backup", created.Name) + s.Assert().Equal("available", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) +} + +func (s *BackupSuite) TestGetBackup() { + volume := s.createVolume("database") + created := s.createBackup(volume.ID, "database-backup") + + found, err := backups.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(volume.ID, found.VolumeID) +} + +func (s *BackupSuite) TestDeleteBackup() { + volume := s.createVolume("database") + created := s.createBackup(volume.ID, "database-backup") + + err := backups.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listBackups() + + s.Assert().Empty(list) +} + +func (s *BackupSuite) listBackups() []backups.Backup { + pages, err := backups.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := backups.ExtractBackups(pages) + s.Require().NoError(err) + + return list +} + +func (s *BackupSuite) createBackup( + volumeID string, + name string, +) *backups.Backup { + created, err := backups.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + backups.CreateOpts{ + VolumeID: volumeID, + Name: name, + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *BackupSuite) 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/api/volume/extra_spec.go b/internal/api/volume/extra_spec.go new file mode 100644 index 0000000..c5293f7 --- /dev/null +++ b/internal/api/volume/extra_spec.go @@ -0,0 +1,105 @@ +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) listExtraSpecs(w http.ResponseWriter, r *http.Request) { + extraSpecs, err := h.service.ListExtraSpecs(chi.URLParam(r, "type_id")) + if handleExtraSpecError(w, err, "extra specs lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, extraSpecsResponse{ + ExtraSpecs: extraSpecs, + }) +} + +func (h Handler) createExtraSpecs(w http.ResponseWriter, r *http.Request) { + var request extraSpecsResponse + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + extraSpecs, err := h.service.CreateExtraSpecs( + chi.URLParam(r, "type_id"), + request.ExtraSpecs, + ) + if handleExtraSpecError(w, err, "extra specs create failed") { + return + } + + respond.JSON(w, http.StatusOK, extraSpecsResponse{ + ExtraSpecs: extraSpecs, + }) +} + +func (h Handler) getExtraSpec(w http.ResponseWriter, r *http.Request) { + extraSpec, err := h.service.GetExtraSpec( + chi.URLParam(r, "type_id"), + chi.URLParam(r, "key"), + ) + if handleExtraSpecError(w, err, "extra spec lookup failed") { + return + } + + respond.JSON(w, http.StatusOK, extraSpec) +} + +func (h Handler) updateExtraSpec(w http.ResponseWriter, r *http.Request) { + var request map[string]string + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + extraSpec, err := h.service.UpdateExtraSpec( + chi.URLParam(r, "type_id"), + request, + ) + if handleExtraSpecError(w, err, "extra spec update failed") { + return + } + + respond.JSON(w, http.StatusOK, extraSpec) +} + +func (h Handler) deleteExtraSpec(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteExtraSpec( + chi.URLParam(r, "type_id"), + chi.URLParam(r, "key"), + ) + if handleExtraSpecError(w, err, "extra spec delete failed") { + return + } + + w.WriteHeader(http.StatusAccepted) +} + +func handleExtraSpecError( + w http.ResponseWriter, + err error, + internalMessage string, +) bool { + if err == nil { + return false + } + if errors.Is(err, appvolume.ErrVolumeTypeNotFound) { + respond.Error(w, http.StatusNotFound, "volume type not found") + return true + } + if errors.Is(err, appvolume.ErrExtraSpecNotFound) { + respond.Error(w, http.StatusNotFound, "extra spec not found") + return true + } + + respond.Error(w, http.StatusInternalServerError, internalMessage) + return true +} diff --git a/internal/api/volume/extra_spec_dto.go b/internal/api/volume/extra_spec_dto.go new file mode 100644 index 0000000..607b1f9 --- /dev/null +++ b/internal/api/volume/extra_spec_dto.go @@ -0,0 +1,5 @@ +package volume + +type extraSpecsResponse struct { + ExtraSpecs map[string]string `json:"extra_specs"` +} diff --git a/internal/api/volume/extra_spec_test.go b/internal/api/volume/extra_spec_test.go new file mode 100644 index 0000000..1292df2 --- /dev/null +++ b/internal/api/volume/extra_spec_test.go @@ -0,0 +1,111 @@ +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/openstack/blockstorage/v3/volumetypes" + "github.com/stretchr/testify/suite" +) + +type ExtraSpecSuite struct { + suite.Suite + server *httptest.Server +} + +func TestExtraSpecSuite(t *testing.T) { + suite.Run(t, new(ExtraSpecSuite)) +} + +func (s *ExtraSpecSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ExtraSpecSuite) TearDownTest() { + s.server.Close() +} + +func (s *ExtraSpecSuite) TestCreateListAndGetExtraSpecs() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + created, err := volumetypes.CreateExtraSpecs( + context.Background(), + client, + "default", + volumetypes.ExtraSpecsOpts{ + "capabilities": "gpu", + }, + ).Extract() + s.Require().NoError(err) + + listed, err := volumetypes.ListExtraSpecs( + context.Background(), + client, + "default", + ).Extract() + s.Require().NoError(err) + + found, err := volumetypes.GetExtraSpec( + context.Background(), + client, + "default", + "capabilities", + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("gpu", created["capabilities"]) + s.Assert().Equal("gpu", listed["capabilities"]) + s.Assert().Equal("gpu", found["capabilities"]) +} + +func (s *ExtraSpecSuite) TestUpdateExtraSpec() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + updated, err := volumetypes.UpdateExtraSpec( + context.Background(), + client, + "default", + volumetypes.ExtraSpecsOpts{ + "capabilities": "ssd", + }, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("ssd", updated["capabilities"]) +} + +func (s *ExtraSpecSuite) TestDeleteExtraSpec() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + + _, err := volumetypes.CreateExtraSpecs( + context.Background(), + client, + "default", + volumetypes.ExtraSpecsOpts{ + "capabilities": "gpu", + }, + ).Extract() + s.Require().NoError(err) + + err = volumetypes.DeleteExtraSpec( + context.Background(), + client, + "default", + "capabilities", + ).ExtractErr() + s.Require().NoError(err) + + listed, err := volumetypes.ListExtraSpecs( + context.Background(), + client, + "default", + ).Extract() + s.Require().NoError(err) + + s.Assert().Empty(listed) +} diff --git a/internal/api/volume/limits.go b/internal/api/volume/limits.go new file mode 100644 index 0000000..32f5580 --- /dev/null +++ b/internal/api/volume/limits.go @@ -0,0 +1,13 @@ +package volume + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) getLimits(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, limitsResponse{ + Limits: toLimitsDocument(h.service.GetLimits()), + }) +} diff --git a/internal/api/volume/limits_dto.go b/internal/api/volume/limits_dto.go new file mode 100644 index 0000000..bafcdaf --- /dev/null +++ b/internal/api/volume/limits_dto.go @@ -0,0 +1,57 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type limitsResponse struct { + Limits limitsDocument `json:"limits"` +} + +type limitsDocument struct { + Absolute absoluteLimitsDocument `json:"absolute"` + Rate []rateLimitDocument `json:"rate"` +} + +type absoluteLimitsDocument struct { + MaxTotalVolumes int `json:"maxTotalVolumes"` + MaxTotalSnapshots int `json:"maxTotalSnapshots"` + MaxTotalVolumeGigabytes int `json:"maxTotalVolumeGigabytes"` + MaxTotalBackups int `json:"maxTotalBackups"` + MaxTotalBackupGigabytes int `json:"maxTotalBackupGigabytes"` + TotalVolumesUsed int `json:"totalVolumesUsed"` + TotalGigabytesUsed int `json:"totalGigabytesUsed"` + TotalSnapshotsUsed int `json:"totalSnapshotsUsed"` + TotalBackupsUsed int `json:"totalBackupsUsed"` + TotalBackupGigabytesUsed int `json:"totalBackupGigabytesUsed"` +} + +type rateLimitDocument struct { + Regex string `json:"regex"` + URI string `json:"uri"` + Limit []rateLimitItemDocument `json:"limit"` +} + +type rateLimitItemDocument struct { + Verb string `json:"verb"` + NextAvailable string `json:"next-available"` + Unit string `json:"unit"` + Value int `json:"value"` + Remaining int `json:"remaining"` +} + +func toLimitsDocument(limits appvolume.Limits) limitsDocument { + return limitsDocument{ + Absolute: absoluteLimitsDocument{ + MaxTotalVolumes: limits.MaxTotalVolumes, + MaxTotalSnapshots: limits.MaxTotalSnapshots, + MaxTotalVolumeGigabytes: limits.MaxTotalVolumeGigabytes, + MaxTotalBackups: limits.MaxTotalBackups, + MaxTotalBackupGigabytes: limits.MaxTotalBackupGigabytes, + TotalVolumesUsed: limits.TotalVolumesUsed, + TotalGigabytesUsed: limits.TotalGigabytesUsed, + TotalSnapshotsUsed: limits.TotalSnapshotsUsed, + TotalBackupsUsed: limits.TotalBackupsUsed, + TotalBackupGigabytesUsed: limits.TotalBackupGigabytesUsed, + }, + Rate: []rateLimitDocument{}, + } +} diff --git a/internal/api/volume/limits_test.go b/internal/api/volume/limits_test.go new file mode 100644 index 0000000..c330455 --- /dev/null +++ b/internal/api/volume/limits_test.go @@ -0,0 +1,55 @@ +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/limits" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type LimitsSuite struct { + suite.Suite + server *httptest.Server +} + +func TestLimitsSuite(t *testing.T) { + suite.Run(t, new(LimitsSuite)) +} + +func (s *LimitsSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *LimitsSuite) TearDownTest() { + s.server.Close() +} + +func (s *LimitsSuite) TestGetLimits() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := volumes.Create( + s.T().Context(), + client, + volumes.CreateOpts{ + Size: 7, + Name: "database", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + result, err := limits.Get(s.T().Context(), client).Extract() + s.Require().NoError(err) + s.Require().NotNil(result) + + s.Assert().Equal(1000, result.Absolute.MaxTotalVolumes) + s.Assert().Equal(1, result.Absolute.TotalVolumesUsed) + s.Assert().Equal(7, result.Absolute.TotalGigabytesUsed) + s.Assert().Empty(result.Rate) +} diff --git a/internal/api/volume/quota.go b/internal/api/volume/quota.go new file mode 100644 index 0000000..900f725 --- /dev/null +++ b/internal/api/volume/quota.go @@ -0,0 +1,63 @@ +package volume + +import ( + "encoding/json" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getQuotaSet(w http.ResponseWriter, r *http.Request) { + projectID := chi.URLParam(r, "quota_project_id") + if r.URL.Query().Get("usage") == "true" { + respond.JSON(w, http.StatusOK, quotaUsageSetResponse{ + QuotaSet: toQuotaUsageSetDocument( + h.service.GetQuotaUsageSet(projectID), + ), + }) + return + } + + respond.JSON(w, http.StatusOK, quotaSetResponse{ + QuotaSet: toQuotaSetDocument(h.service.GetQuotaSet(projectID)), + }) +} + +func (h Handler) getDefaultQuotaSet(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, quotaSetResponse{ + QuotaSet: toQuotaSetDocument( + h.service.GetDefaultQuotaSet( + chi.URLParam(r, "quota_project_id"), + ), + ), + }) +} + +func (h Handler) updateQuotaSet(w http.ResponseWriter, r *http.Request) { + var request updateQuotaSetRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid quota request") + return + } + + quotaSet := h.service.UpdateQuotaSet( + chi.URLParam(r, "quota_project_id"), + request.updateQuotaSet(), + ) + + respond.JSON(w, http.StatusOK, quotaSetResponse{ + QuotaSet: toQuotaSetDocument(quotaSet), + }) +} + +func (h Handler) deleteQuotaSet(w http.ResponseWriter, r *http.Request) { + if err := h.service.ResetQuotaSet( + chi.URLParam(r, "quota_project_id"), + ); err != nil { + respond.Error(w, http.StatusInternalServerError, "quota reset failed") + return + } + + respond.JSON(w, http.StatusOK, map[string]any{}) +} diff --git a/internal/api/volume/quota_dto.go b/internal/api/volume/quota_dto.go new file mode 100644 index 0000000..9d78d57 --- /dev/null +++ b/internal/api/volume/quota_dto.go @@ -0,0 +1,103 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type quotaSetResponse struct { + QuotaSet quotaSetDocument `json:"quota_set"` +} + +type updateQuotaSetRequest struct { + QuotaSet updateQuotaSetDocument `json:"quota_set"` +} + +type quotaSetDocument struct { + ID string `json:"id"` + Volumes int `json:"volumes"` + Snapshots int `json:"snapshots"` + Gigabytes int `json:"gigabytes"` + PerVolumeGigabytes int `json:"per_volume_gigabytes"` + Backups int `json:"backups"` + BackupGigabytes int `json:"backup_gigabytes"` + Groups int `json:"groups"` +} + +type updateQuotaSetDocument struct { + Volumes *int `json:"volumes"` + Snapshots *int `json:"snapshots"` + Gigabytes *int `json:"gigabytes"` + PerVolumeGigabytes *int `json:"per_volume_gigabytes"` + Backups *int `json:"backups"` + BackupGigabytes *int `json:"backup_gigabytes"` + Groups *int `json:"groups"` +} + +type quotaUsageSetResponse struct { + QuotaSet quotaUsageSetDocument `json:"quota_set"` +} + +type quotaUsageSetDocument struct { + ID string `json:"id"` + Volumes quotaUsageDocument `json:"volumes"` + Snapshots quotaUsageDocument `json:"snapshots"` + Gigabytes quotaUsageDocument `json:"gigabytes"` + PerVolumeGigabytes quotaUsageDocument `json:"per_volume_gigabytes"` + Backups quotaUsageDocument `json:"backups"` + BackupGigabytes quotaUsageDocument `json:"backup_gigabytes"` + Groups quotaUsageDocument `json:"groups"` +} + +type quotaUsageDocument struct { + InUse int `json:"in_use"` + Reserved int `json:"reserved"` + Limit int `json:"limit"` + Allocated int `json:"allocated"` +} + +func (request updateQuotaSetRequest) updateQuotaSet() appvolume.UpdateQuotaSet { + return appvolume.UpdateQuotaSet{ + Volumes: request.QuotaSet.Volumes, + Snapshots: request.QuotaSet.Snapshots, + Gigabytes: request.QuotaSet.Gigabytes, + PerVolumeGigabytes: request.QuotaSet.PerVolumeGigabytes, + Backups: request.QuotaSet.Backups, + BackupGigabytes: request.QuotaSet.BackupGigabytes, + Groups: request.QuotaSet.Groups, + } +} + +func toQuotaSetDocument(quotaSet appvolume.QuotaSet) quotaSetDocument { + return quotaSetDocument{ + ID: quotaSet.ID, + Volumes: quotaSet.Volumes, + Snapshots: quotaSet.Snapshots, + Gigabytes: quotaSet.Gigabytes, + PerVolumeGigabytes: quotaSet.PerVolumeGigabytes, + Backups: quotaSet.Backups, + BackupGigabytes: quotaSet.BackupGigabytes, + Groups: quotaSet.Groups, + } +} + +func toQuotaUsageSetDocument( + usageSet appvolume.QuotaUsageSet, +) quotaUsageSetDocument { + return quotaUsageSetDocument{ + ID: usageSet.ID, + Volumes: toQuotaUsageDocument(usageSet.Volumes), + Snapshots: toQuotaUsageDocument(usageSet.Snapshots), + Gigabytes: toQuotaUsageDocument(usageSet.Gigabytes), + PerVolumeGigabytes: toQuotaUsageDocument(usageSet.PerVolumeGigabytes), + Backups: toQuotaUsageDocument(usageSet.Backups), + BackupGigabytes: toQuotaUsageDocument(usageSet.BackupGigabytes), + Groups: toQuotaUsageDocument(usageSet.Groups), + } +} + +func toQuotaUsageDocument(usage appvolume.QuotaUsage) quotaUsageDocument { + return quotaUsageDocument{ + InUse: usage.InUse, + Reserved: usage.Reserved, + Limit: usage.Limit, + Allocated: usage.Allocated, + } +} diff --git a/internal/api/volume/quota_test.go b/internal/api/volume/quota_test.go new file mode 100644 index 0000000..941f93a --- /dev/null +++ b/internal/api/volume/quota_test.go @@ -0,0 +1,117 @@ +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" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/quotasets" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type QuotaSuite struct { + suite.Suite + server *httptest.Server +} + +func TestQuotaSuite(t *testing.T) { + suite.Run(t, new(QuotaSuite)) +} + +func (s *QuotaSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *QuotaSuite) TearDownTest() { + s.server.Close() +} + +func (s *QuotaSuite) TestGetQuotaSet() { + quotaSet, err := quotasets.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + "demo", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(quotaSet) + + s.Assert().Equal("demo", quotaSet.ID) + s.Assert().Equal(1000, quotaSet.Volumes) + s.Assert().Equal(-1, quotaSet.PerVolumeGigabytes) +} + +func (s *QuotaSuite) TestGetDefaultQuotaSet() { + quotaSet, err := quotasets.GetDefaults( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + "demo", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(quotaSet) + + s.Assert().Equal("demo", quotaSet.ID) + s.Assert().Equal(1000, quotaSet.Backups) +} + +func (s *QuotaSuite) TestUpdateAndDeleteQuotaSet() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + quotaSet, err := quotasets.Update( + s.T().Context(), + client, + "demo", + quotasets.UpdateOpts{ + Volumes: gophercloud.IntToPointer(12), + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(quotaSet) + s.Assert().Equal(12, quotaSet.Volumes) + + err = quotasets.Delete( + s.T().Context(), + client, + "demo", + ).ExtractErr() + s.Require().NoError(err) + + quotaSet, err = quotasets.Get( + s.T().Context(), + client, + "demo", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(quotaSet) + s.Assert().Equal(1000, quotaSet.Volumes) +} + +func (s *QuotaSuite) TestGetQuotaUsageSet() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := volumes.Create( + s.T().Context(), + client, + volumes.CreateOpts{ + Size: 3, + Name: "database", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + usageSet, err := quotasets.GetUsage( + s.T().Context(), + client, + "demo", + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal("demo", usageSet.ID) + s.Assert().Equal(1, usageSet.Volumes.InUse) + s.Assert().Equal(3, usageSet.Gigabytes.InUse) + s.Assert().Equal(1000, usageSet.Volumes.Limit) +} diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index 604d1d8..787698a 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -32,6 +32,11 @@ func NewHandler(cfg config.Config) Handler { cfg, appvolume.NewServiceWithRuntime( storevolume.NewMemoryRepository(), + storevolume.NewMemorySnapshotRepository(), + storevolume.NewMemoryTransferRepository(), + storevolume.NewMemoryBackupRepository(), + storevolume.NewMemoryAttachmentRepository(), + storevolume.NewMemoryQuotaRepository(), clock.Wall(), idgen.Random(), ), @@ -52,13 +57,49 @@ 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}/limits", h.getLimits) + router.Get("/{project_id}/os-quota-sets/{quota_project_id}", h.getQuotaSet) + router.Put("/{project_id}/os-quota-sets/{quota_project_id}", h.updateQuotaSet) + router.Delete("/{project_id}/os-quota-sets/{quota_project_id}", h.deleteQuotaSet) + router.Get( + "/{project_id}/os-quota-sets/{quota_project_id}/defaults", + h.getDefaultQuotaSet, + ) 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.Put("/{project_id}/volumes/{volume_id}", h.updateVolume) router.Delete("/{project_id}/volumes/{volume_id}", h.deleteVolume) + router.Post("/{project_id}/volumes/{volume_id}/action", h.actionVolume) + router.Get("/{project_id}/snapshots", h.listSnapshots) + router.Get("/{project_id}/snapshots/detail", h.listSnapshots) + router.Post("/{project_id}/snapshots", h.createSnapshot) + router.Get("/{project_id}/snapshots/{snapshot_id}", h.getSnapshot) + router.Delete("/{project_id}/snapshots/{snapshot_id}", h.deleteSnapshot) + router.Get("/{project_id}/os-volume-transfer/detail", h.listTransfers) + router.Post("/{project_id}/os-volume-transfer", h.createTransfer) + router.Get("/{project_id}/os-volume-transfer/{transfer_id}", h.getTransfer) + router.Delete("/{project_id}/os-volume-transfer/{transfer_id}", h.deleteTransfer) + router.Post("/{project_id}/os-volume-transfer/{transfer_id}/accept", h.acceptTransfer) + router.Get("/{project_id}/backups", h.listBackups) + router.Get("/{project_id}/backups/detail", h.listBackups) + router.Post("/{project_id}/backups", h.createBackup) + router.Get("/{project_id}/backups/{backup_id}", h.getBackup) + router.Delete("/{project_id}/backups/{backup_id}", h.deleteBackup) + router.Get("/{project_id}/attachments/detail", h.listAttachments) + router.Post("/{project_id}/attachments", h.createAttachment) + router.Get("/{project_id}/attachments/{attachment_id}", h.getAttachment) + router.Put("/{project_id}/attachments/{attachment_id}", h.updateAttachment) + router.Delete("/{project_id}/attachments/{attachment_id}", h.deleteAttachment) + router.Post("/{project_id}/attachments/{attachment_id}/action", h.actionAttachment) router.Get("/{project_id}/types", h.listVolumeTypes) router.Get("/{project_id}/types/{type_id}", h.getVolumeType) + router.Get("/{project_id}/types/{type_id}/extra_specs", h.listExtraSpecs) + router.Post("/{project_id}/types/{type_id}/extra_specs", h.createExtraSpecs) + router.Get("/{project_id}/types/{type_id}/extra_specs/{key}", h.getExtraSpec) + router.Put("/{project_id}/types/{type_id}/extra_specs/{key}", h.updateExtraSpec) + router.Delete("/{project_id}/types/{type_id}/extra_specs/{key}", h.deleteExtraSpec) + router.Get("/{project_id}/os-availability-zone", h.listAvailabilityZones) return router } diff --git a/internal/api/volume/snapshot.go b/internal/api/volume/snapshot.go new file mode 100644 index 0000000..d4fc05c --- /dev/null +++ b/internal/api/volume/snapshot.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) listSnapshots(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, snapshotListResponse{ + Snapshots: toSnapshotDocuments(h.service.ListSnapshots()), + }) +} + +func (h Handler) createSnapshot(w http.ResponseWriter, r *http.Request) { + var request createSnapshotRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + snapshot := h.service.CreateSnapshot(request.createSnapshot()) + respond.JSON(w, http.StatusAccepted, snapshotResponse{ + Snapshot: toSnapshotDocument(snapshot), + }) +} + +func (h Handler) getSnapshot(w http.ResponseWriter, r *http.Request) { + snapshot, err := h.service.GetSnapshot(chi.URLParam(r, "snapshot_id")) + if errors.Is(err, appvolume.ErrSnapshotNotFound) { + respond.Error(w, http.StatusNotFound, "snapshot not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "snapshot lookup failed") + return + } + + respond.JSON(w, http.StatusOK, snapshotResponse{ + Snapshot: toSnapshotDocument(snapshot), + }) +} + +func (h Handler) deleteSnapshot(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteSnapshot(chi.URLParam(r, "snapshot_id")) + if errors.Is(err, appvolume.ErrSnapshotNotFound) { + respond.Error(w, http.StatusNotFound, "snapshot not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "snapshot delete failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/api/volume/snapshot_dto.go b/internal/api/volume/snapshot_dto.go new file mode 100644 index 0000000..9d41761 --- /dev/null +++ b/internal/api/volume/snapshot_dto.go @@ -0,0 +1,74 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type createSnapshotRequest struct { + Snapshot createSnapshotDocument `json:"snapshot"` +} + +type createSnapshotDocument struct { + Name string `json:"name"` + Description string `json:"description"` + VolumeID string `json:"volume_id"` + Force bool `json:"force"` + Metadata map[string]string `json:"metadata"` +} + +func (r createSnapshotRequest) createSnapshot() appvolume.CreateSnapshot { + return appvolume.CreateSnapshot{ + Name: r.Snapshot.Name, + Description: r.Snapshot.Description, + VolumeID: r.Snapshot.VolumeID, + Force: r.Snapshot.Force, + Metadata: r.Snapshot.Metadata, + } +} + +type snapshotListResponse struct { + Snapshots []snapshotDocument `json:"snapshots"` +} + +type snapshotResponse struct { + Snapshot snapshotDocument `json:"snapshot"` +} + +type snapshotDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + VolumeID string `json:"volume_id"` + Status string `json:"status"` + Size int `json:"size"` + Metadata map[string]string `json:"metadata"` + Progress string `json:"os-extended-snapshot-attributes:progress"` + ProjectID string `json:"os-extended-snapshot-attributes:project_id"` + UserID string `json:"user_id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toSnapshotDocuments(snapshots []appvolume.Snapshot) []snapshotDocument { + documents := make([]snapshotDocument, 0, len(snapshots)) + for _, snapshot := range snapshots { + documents = append(documents, toSnapshotDocument(snapshot)) + } + + return documents +} + +func toSnapshotDocument(snapshot appvolume.Snapshot) snapshotDocument { + return snapshotDocument{ + ID: snapshot.ID, + Name: snapshot.Name, + Description: snapshot.Description, + VolumeID: snapshot.VolumeID, + Status: snapshot.Status, + Size: snapshot.Size, + Metadata: snapshot.Metadata, + Progress: snapshot.Progress, + ProjectID: snapshot.ProjectID, + UserID: snapshot.UserID, + CreatedAt: snapshot.CreatedAt, + UpdatedAt: snapshot.UpdatedAt, + } +} diff --git a/internal/api/volume/snapshot_test.go b/internal/api/volume/snapshot_test.go new file mode 100644 index 0000000..6706332 --- /dev/null +++ b/internal/api/volume/snapshot_test.go @@ -0,0 +1,130 @@ +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/snapshots" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type SnapshotSuite struct { + suite.Suite + server *httptest.Server +} + +func TestSnapshotSuite(t *testing.T) { + suite.Run(t, new(SnapshotSuite)) +} + +func (s *SnapshotSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *SnapshotSuite) TearDownTest() { + s.server.Close() +} + +func (s *SnapshotSuite) TestListSnapshots() { + list := s.listSnapshots() + + s.Assert().Empty(list) +} + +func (s *SnapshotSuite) TestCreateSnapshotThenListSnapshots() { + volume := s.createVolume("database") + + created := s.createSnapshot(volume.ID, "database-snapshot") + list := s.listSnapshots() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal(volume.ID, created.VolumeID) + s.Assert().Equal("database-snapshot", created.Name) + s.Assert().Equal("available", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) +} + +func (s *SnapshotSuite) TestGetSnapshot() { + volume := s.createVolume("database") + created := s.createSnapshot(volume.ID, "database-snapshot") + + found, err := snapshots.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(volume.ID, found.VolumeID) +} + +func (s *SnapshotSuite) TestDeleteSnapshot() { + volume := s.createVolume("database") + created := s.createSnapshot(volume.ID, "database-snapshot") + + err := snapshots.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listSnapshots() + + s.Assert().Empty(list) +} + +func (s *SnapshotSuite) listSnapshots() []snapshots.Snapshot { + pages, err := snapshots.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := snapshots.ExtractSnapshots(pages) + s.Require().NoError(err) + + return list +} + +func (s *SnapshotSuite) createSnapshot( + volumeID string, + name string, +) *snapshots.Snapshot { + created, err := snapshots.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + snapshots.CreateOpts{ + VolumeID: volumeID, + Name: name, + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *SnapshotSuite) 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/api/volume/transfer.go b/internal/api/volume/transfer.go new file mode 100644 index 0000000..ecbb706 --- /dev/null +++ b/internal/api/volume/transfer.go @@ -0,0 +1,85 @@ +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) listTransfers(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, transferListResponse{ + Transfers: toTransferDocuments(h.service.ListTransfers()), + }) +} + +func (h Handler) createTransfer(w http.ResponseWriter, r *http.Request) { + var request createTransferRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + transfer := h.service.CreateTransfer(request.createTransfer()) + respond.JSON(w, http.StatusAccepted, transferResponse{ + Transfer: toTransferDocument(transfer), + }) +} + +func (h Handler) getTransfer(w http.ResponseWriter, r *http.Request) { + transfer, err := h.service.GetTransfer(chi.URLParam(r, "transfer_id")) + if errors.Is(err, appvolume.ErrTransferNotFound) { + respond.Error(w, http.StatusNotFound, "transfer not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "transfer lookup failed") + return + } + + respond.JSON(w, http.StatusOK, transferResponse{ + Transfer: toTransferDocument(transfer), + }) +} + +func (h Handler) deleteTransfer(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteTransfer(chi.URLParam(r, "transfer_id")) + if errors.Is(err, appvolume.ErrTransferNotFound) { + respond.Error(w, http.StatusNotFound, "transfer not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "transfer delete failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} + +func (h Handler) acceptTransfer(w http.ResponseWriter, r *http.Request) { + var request acceptTransferRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + transfer, err := h.service.AcceptTransfer( + chi.URLParam(r, "transfer_id"), + request.Accept.AuthKey, + ) + if errors.Is(err, appvolume.ErrTransferNotFound) { + respond.Error(w, http.StatusNotFound, "transfer not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "transfer accept failed") + return + } + + respond.JSON(w, http.StatusAccepted, transferResponse{ + Transfer: toTransferDocument(transfer), + }) +} diff --git a/internal/api/volume/transfer_dto.go b/internal/api/volume/transfer_dto.go new file mode 100644 index 0000000..5eb51c5 --- /dev/null +++ b/internal/api/volume/transfer_dto.go @@ -0,0 +1,62 @@ +package volume + +import appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" + +type createTransferRequest struct { + Transfer createTransferDocument `json:"transfer"` +} + +type createTransferDocument struct { + Name string `json:"name"` + VolumeID string `json:"volume_id"` +} + +func (r createTransferRequest) createTransfer() appvolume.CreateTransfer { + return appvolume.CreateTransfer{ + Name: r.Transfer.Name, + VolumeID: r.Transfer.VolumeID, + } +} + +type acceptTransferRequest struct { + Accept struct { + AuthKey string `json:"auth_key"` + } `json:"accept"` +} + +type transferListResponse struct { + Transfers []transferDocument `json:"transfers"` +} + +type transferResponse struct { + Transfer transferDocument `json:"transfer"` +} + +type transferDocument struct { + ID string `json:"id"` + AuthKey string `json:"auth_key,omitempty"` + Name string `json:"name"` + VolumeID string `json:"volume_id"` + CreatedAt string `json:"created_at"` + Links []map[string]string `json:"links"` +} + +func toTransferDocuments(transfers []appvolume.Transfer) []transferDocument { + documents := make([]transferDocument, 0, len(transfers)) + for _, transfer := range transfers { + documents = append(documents, toTransferDocument(transfer)) + } + + return documents +} + +func toTransferDocument(transfer appvolume.Transfer) transferDocument { + return transferDocument{ + ID: transfer.ID, + AuthKey: transfer.AuthKey, + Name: transfer.Name, + VolumeID: transfer.VolumeID, + CreatedAt: transfer.CreatedAt, + Links: transfer.Links, + } +} diff --git a/internal/api/volume/transfer_test.go b/internal/api/volume/transfer_test.go new file mode 100644 index 0000000..f496cd4 --- /dev/null +++ b/internal/api/volume/transfer_test.go @@ -0,0 +1,144 @@ +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/transfers" + "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/stretchr/testify/suite" +) + +type TransferSuite struct { + suite.Suite + server *httptest.Server +} + +func TestTransferSuite(t *testing.T) { + suite.Run(t, new(TransferSuite)) +} + +func (s *TransferSuite) SetupTest() { + s.server = httptest.NewServer( + volume.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *TransferSuite) TearDownTest() { + s.server.Close() +} + +func (s *TransferSuite) TestCreateTransferThenListTransfers() { + volume := s.createVolume("database") + + created := s.createTransfer(volume.ID, "database-transfer") + list := s.listTransfers() + + s.Assert().NotEmpty(created.ID) + s.Assert().NotEmpty(created.AuthKey) + s.Assert().Equal(volume.ID, created.VolumeID) + s.Assert().Equal("database-transfer", created.Name) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) +} + +func (s *TransferSuite) TestGetTransfer() { + volume := s.createVolume("database") + created := s.createTransfer(volume.ID, "database-transfer") + + found, err := transfers.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(volume.ID, found.VolumeID) +} + +func (s *TransferSuite) TestDeleteTransfer() { + volume := s.createVolume("database") + created := s.createTransfer(volume.ID, "database-transfer") + + err := transfers.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listTransfers() + + s.Assert().Empty(list) +} + +func (s *TransferSuite) TestAcceptTransfer() { + volume := s.createVolume("database") + created := s.createTransfer(volume.ID, "database-transfer") + + accepted, err := transfers.Accept( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + transfers.AcceptOpts{AuthKey: created.AuthKey}, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(accepted) + + list := s.listTransfers() + + s.Assert().Equal(created.ID, accepted.ID) + s.Assert().Equal(volume.ID, accepted.VolumeID) + s.Assert().Empty(list) +} + +func (s *TransferSuite) listTransfers() []transfers.Transfer { + pages, err := transfers.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := transfers.ExtractTransfers(pages) + s.Require().NoError(err) + + return list +} + +func (s *TransferSuite) createTransfer( + volumeID string, + name string, +) *transfers.Transfer { + created, err := transfers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + transfers.CreateOpts{ + VolumeID: volumeID, + Name: name, + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *TransferSuite) 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/action.go b/internal/app/volume/action.go new file mode 100644 index 0000000..001030c --- /dev/null +++ b/internal/app/volume/action.go @@ -0,0 +1,73 @@ +package volume + +type AttachVolume struct { + InstanceUUID string + HostName string + MountPoint string + Mode string +} + +type DetachVolume struct { + AttachmentID string +} + +type ResetVolumeStatus struct { + Status string + AttachStatus string +} + +func (s *Service) Attach(id string, input AttachVolume) error { + volume, err := s.repository.Get(id) + if err != nil { + return err + } + + volume.Status = "in-use" + volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + _, err = s.repository.Update(volume) + return err +} + +func (s *Service) BeginDetaching(id string) error { + return s.setVolumeStatus(id, "detaching") +} + +func (s *Service) Detach(id string, input DetachVolume) error { + return s.setVolumeStatus(id, "available") +} + +func (s *Service) Reserve(id string) error { + return s.setVolumeStatus(id, "reserved") +} + +func (s *Service) Unreserve(id string) error { + return s.setVolumeStatus(id, "available") +} + +func (s *Service) ExtendSize(id string, newSize int) error { + volume, err := s.repository.Get(id) + if err != nil { + return err + } + + volume.Size = newSize + volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + _, err = s.repository.Update(volume) + return err +} + +func (s *Service) ResetStatus(id string, input ResetVolumeStatus) error { + return s.setVolumeStatus(id, input.Status) +} + +func (s *Service) setVolumeStatus(id string, status string) error { + volume, err := s.repository.Get(id) + if err != nil { + return err + } + + volume.Status = status + volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + _, err = s.repository.Update(volume) + return err +} diff --git a/internal/app/volume/attachment.go b/internal/app/volume/attachment.go new file mode 100644 index 0000000..5af7643 --- /dev/null +++ b/internal/app/volume/attachment.go @@ -0,0 +1,79 @@ +package volume + +import "errors" + +var ErrAttachmentNotFound = errors.New("attachment not found") + +func (s *Service) CreateAttachment(input CreateAttachment) Attachment { + now := s.clock.Now().UTC().Format(timestampFormat) + mode := input.Mode + if mode == "" { + mode = "rw" + } + + attachment := Attachment{ + ID: "att-" + s.idGen.Hex(16), + VolumeID: input.VolumeID, + Instance: input.InstanceID, + AttachedAt: now, + Status: "reserved", + AttachMode: mode, + ConnectionInfo: map[string]any{}, + Connector: input.Connector, + } + if len(input.Connector) > 0 { + attachment.Status = "attaching" + } + + return s.attachmentRepository.Create(attachment) +} + +func (s *Service) ListAttachments() []Attachment { + return s.attachmentRepository.List() +} + +func (s *Service) GetAttachment(id string) (Attachment, error) { + return s.attachmentRepository.Get(id) +} + +func (s *Service) UpdateAttachment( + id string, + input UpdateAttachment, +) (Attachment, error) { + attachment, err := s.attachmentRepository.Get(id) + if err != nil { + return Attachment{}, err + } + + attachment.Connector = input.Connector + attachment.ConnectionInfo = map[string]any{ + "driver_volume_type": "sandstack", + } + attachment.Status = "attaching" + + return s.attachmentRepository.Update(attachment) +} + +func (s *Service) CompleteAttachment(id string) error { + attachment, err := s.attachmentRepository.Get(id) + if err != nil { + return err + } + + attachment.Status = "attached" + _, err = s.attachmentRepository.Update(attachment) + + return err +} + +func (s *Service) DeleteAttachment(id string) error { + attachment, err := s.attachmentRepository.Get(id) + if err != nil { + return err + } + + attachment.Status = "detached" + attachment.DetachedAt = s.clock.Now().UTC().Format(timestampFormat) + + return s.attachmentRepository.Delete(id) +} diff --git a/internal/app/volume/availability_zone.go b/internal/app/volume/availability_zone.go new file mode 100644 index 0000000..9104ae1 --- /dev/null +++ b/internal/app/volume/availability_zone.go @@ -0,0 +1,10 @@ +package volume + +func (s *Service) ListAvailabilityZones() []AvailabilityZone { + return []AvailabilityZone{ + { + Name: "nova", + Available: true, + }, + } +} diff --git a/internal/app/volume/backup.go b/internal/app/volume/backup.go new file mode 100644 index 0000000..e746036 --- /dev/null +++ b/internal/app/volume/backup.go @@ -0,0 +1,50 @@ +package volume + +import "errors" + +var ErrBackupNotFound = errors.New("backup not found") + +func (s *Service) CreateBackup(input CreateBackup) Backup { + size := 1 + volume, err := s.repository.Get(input.VolumeID) + if err == nil { + size = volume.Size + } + + now := s.clock.Now().UTC().Format(timestampFormat) + backup := Backup{ + ID: "backup-" + s.idGen.Hex(16), + Name: input.Name, + Description: input.Description, + VolumeID: input.VolumeID, + SnapshotID: input.SnapshotID, + Status: "available", + Size: size, + ObjectCount: 1, + Container: input.Container, + IsIncremental: input.Incremental, + ProjectID: "demo", + Metadata: input.Metadata, + AvailabilityZone: input.AvailabilityZone, + CreatedAt: now, + UpdatedAt: now, + DataTimestamp: now, + } + if backup.Metadata == nil { + backup.Metadata = map[string]string{} + } + + return s.backupRepository.Create(backup) +} + +func (s *Service) ListBackups() []Backup { + return s.backupRepository.List() +} + +func (s *Service) GetBackup(id string) (Backup, error) { + return s.backupRepository.Get(id) +} + +func (s *Service) DeleteBackup(id string) error { + return s.backupRepository.Delete(id) +} diff --git a/internal/app/volume/limits.go b/internal/app/volume/limits.go new file mode 100644 index 0000000..5d6f1b7 --- /dev/null +++ b/internal/app/volume/limits.go @@ -0,0 +1,46 @@ +package volume + +const ( + defaultMaxTotalVolumes = 1000 + defaultMaxTotalSnapshots = 1000 + defaultMaxTotalVolumeGigabytes = 100000 + defaultMaxTotalBackups = 1000 + defaultMaxTotalBackupGigabytes = 100000 +) + +func (s *Service) GetLimits() Limits { + volumes := s.repository.List() + snapshots := s.snapshotRepository.List() + backups := s.backupRepository.List() + + return Limits{ + MaxTotalVolumes: defaultMaxTotalVolumes, + MaxTotalSnapshots: defaultMaxTotalSnapshots, + MaxTotalVolumeGigabytes: defaultMaxTotalVolumeGigabytes, + MaxTotalBackups: defaultMaxTotalBackups, + MaxTotalBackupGigabytes: defaultMaxTotalBackupGigabytes, + TotalVolumesUsed: len(volumes), + TotalGigabytesUsed: totalVolumeSize(volumes), + TotalSnapshotsUsed: len(snapshots), + TotalBackupsUsed: len(backups), + TotalBackupGigabytesUsed: totalBackupSize(backups), + } +} + +func totalVolumeSize(volumes []Volume) int { + total := 0 + for _, volume := range volumes { + total += volume.Size + } + + return total +} + +func totalBackupSize(backups []Backup) int { + total := 0 + for _, backup := range backups { + total += backup.Size + } + + return total +} diff --git a/internal/app/volume/quota.go b/internal/app/volume/quota.go new file mode 100644 index 0000000..3ccbdb1 --- /dev/null +++ b/internal/app/volume/quota.go @@ -0,0 +1,110 @@ +package volume + +import "errors" + +var ErrQuotaSetNotFound = errors.New("quota set not found") + +func (s *Service) GetQuotaSet(projectID string) QuotaSet { + quotaSet, err := s.quotaRepository.Get(projectID) + if err == nil { + return quotaSet + } + + return defaultQuotaSet(projectID) +} + +func (s *Service) GetDefaultQuotaSet(projectID string) QuotaSet { + return defaultQuotaSet(projectID) +} + +func (s *Service) UpdateQuotaSet( + projectID string, + input UpdateQuotaSet, +) QuotaSet { + quotaSet := s.GetQuotaSet(projectID) + applyQuotaUpdate("aSet, input) + + return s.quotaRepository.Save(quotaSet) +} + +func (s *Service) ResetQuotaSet(projectID string) error { + return s.quotaRepository.Delete(projectID) +} + +func (s *Service) GetQuotaUsageSet(projectID string) QuotaUsageSet { + quotaSet := s.GetQuotaSet(projectID) + limits := s.GetLimits() + + return QuotaUsageSet{ + ID: projectID, + Volumes: quotaUsage( + limits.TotalVolumesUsed, + quotaSet.Volumes, + ), + Snapshots: quotaUsage( + limits.TotalSnapshotsUsed, + quotaSet.Snapshots, + ), + Gigabytes: quotaUsage( + limits.TotalGigabytesUsed, + quotaSet.Gigabytes, + ), + PerVolumeGigabytes: quotaUsage( + 0, + quotaSet.PerVolumeGigabytes, + ), + Backups: quotaUsage( + limits.TotalBackupsUsed, + quotaSet.Backups, + ), + BackupGigabytes: quotaUsage( + limits.TotalBackupGigabytesUsed, + quotaSet.BackupGigabytes, + ), + Groups: quotaUsage(0, quotaSet.Groups), + } +} + +func defaultQuotaSet(projectID string) QuotaSet { + return QuotaSet{ + ID: projectID, + Volumes: defaultMaxTotalVolumes, + Snapshots: defaultMaxTotalSnapshots, + Gigabytes: defaultMaxTotalVolumeGigabytes, + PerVolumeGigabytes: -1, + Backups: defaultMaxTotalBackups, + BackupGigabytes: defaultMaxTotalBackupGigabytes, + Groups: 10, + } +} + +func applyQuotaUpdate(quotaSet *QuotaSet, input UpdateQuotaSet) { + if input.Volumes != nil { + quotaSet.Volumes = *input.Volumes + } + if input.Snapshots != nil { + quotaSet.Snapshots = *input.Snapshots + } + if input.Gigabytes != nil { + quotaSet.Gigabytes = *input.Gigabytes + } + if input.PerVolumeGigabytes != nil { + quotaSet.PerVolumeGigabytes = *input.PerVolumeGigabytes + } + if input.Backups != nil { + quotaSet.Backups = *input.Backups + } + if input.BackupGigabytes != nil { + quotaSet.BackupGigabytes = *input.BackupGigabytes + } + if input.Groups != nil { + quotaSet.Groups = *input.Groups + } +} + +func quotaUsage(inUse int, limit int) QuotaUsage { + return QuotaUsage{ + InUse: inUse, + Limit: limit, + } +} diff --git a/internal/app/volume/repository.go b/internal/app/volume/repository.go index b099306..e83c8de 100644 --- a/internal/app/volume/repository.go +++ b/internal/app/volume/repository.go @@ -8,3 +8,43 @@ type Repository interface { Delete(id string) error Reset() } + +type SnapshotRepository interface { + Create(snapshot Snapshot) Snapshot + List() []Snapshot + Get(id string) (Snapshot, error) + Delete(id string) error + Reset() +} + +type TransferRepository interface { + Create(transfer Transfer) Transfer + List() []Transfer + Get(id string) (Transfer, error) + Delete(id string) error + Reset() +} + +type BackupRepository interface { + Create(backup Backup) Backup + List() []Backup + Get(id string) (Backup, error) + Delete(id string) error + Reset() +} + +type AttachmentRepository interface { + Create(attachment Attachment) Attachment + List() []Attachment + Get(id string) (Attachment, error) + Update(attachment Attachment) (Attachment, error) + Delete(id string) error + Reset() +} + +type QuotaRepository interface { + Get(projectID string) (QuotaSet, error) + Save(quotaSet QuotaSet) QuotaSet + Delete(projectID string) error + Reset() +} diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 28c0c90..5d84085 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -6,19 +6,34 @@ import ( ) type Service struct { - repository Repository - volumeTypes []VolumeType - clock clock.Clock - idGen idgen.Generator + repository Repository + snapshotRepository SnapshotRepository + transferRepository TransferRepository + backupRepository BackupRepository + attachmentRepository AttachmentRepository + quotaRepository QuotaRepository + volumeTypes []VolumeType + clock clock.Clock + idGen idgen.Generator } func NewServiceWithRuntime( repository Repository, + snapshotRepository SnapshotRepository, + transferRepository TransferRepository, + backupRepository BackupRepository, + attachmentRepository AttachmentRepository, + quotaRepository QuotaRepository, clock clock.Clock, idGen idgen.Generator, ) *Service { return &Service{ - repository: repository, + repository: repository, + snapshotRepository: snapshotRepository, + transferRepository: transferRepository, + backupRepository: backupRepository, + attachmentRepository: attachmentRepository, + quotaRepository: quotaRepository, volumeTypes: []VolumeType{ { ID: "default", @@ -35,4 +50,9 @@ func NewServiceWithRuntime( func (s *Service) Reset() { s.repository.Reset() + s.snapshotRepository.Reset() + s.transferRepository.Reset() + s.backupRepository.Reset() + s.attachmentRepository.Reset() + s.quotaRepository.Reset() } diff --git a/internal/app/volume/snapshot.go b/internal/app/volume/snapshot.go new file mode 100644 index 0000000..37fbe40 --- /dev/null +++ b/internal/app/volume/snapshot.go @@ -0,0 +1,46 @@ +package volume + +import "errors" + +var ErrSnapshotNotFound = errors.New("snapshot not found") + +func (s *Service) CreateSnapshot(input CreateSnapshot) Snapshot { + size := 1 + volume, err := s.repository.Get(input.VolumeID) + if err == nil { + size = volume.Size + } + + now := s.clock.Now().UTC().Format(timestampFormat) + snapshot := Snapshot{ + ID: "snap-" + s.idGen.Hex(16), + Name: input.Name, + Description: input.Description, + VolumeID: input.VolumeID, + Status: "available", + Size: size, + Metadata: input.Metadata, + Progress: "100%", + ProjectID: "demo", + UserID: "demo", + CreatedAt: now, + UpdatedAt: now, + } + if snapshot.Metadata == nil { + snapshot.Metadata = map[string]string{} + } + + return s.snapshotRepository.Create(snapshot) +} + +func (s *Service) ListSnapshots() []Snapshot { + return s.snapshotRepository.List() +} + +func (s *Service) GetSnapshot(id string) (Snapshot, error) { + return s.snapshotRepository.Get(id) +} + +func (s *Service) DeleteSnapshot(id string) error { + return s.snapshotRepository.Delete(id) +} diff --git a/internal/app/volume/transfer.go b/internal/app/volume/transfer.go new file mode 100644 index 0000000..b6f77f9 --- /dev/null +++ b/internal/app/volume/transfer.go @@ -0,0 +1,45 @@ +package volume + +import "errors" + +var ErrTransferNotFound = errors.New("transfer not found") + +func (s *Service) CreateTransfer(input CreateTransfer) Transfer { + transfer := Transfer{ + ID: "transfer-" + s.idGen.Hex(16), + AuthKey: "auth-" + s.idGen.Hex(16), + Name: input.Name, + VolumeID: input.VolumeID, + CreatedAt: s.clock.Now().UTC().Format(timestampFormat), + Links: []map[string]string{}, + } + + return s.transferRepository.Create(transfer) +} + +func (s *Service) ListTransfers() []Transfer { + return s.transferRepository.List() +} + +func (s *Service) GetTransfer(id string) (Transfer, error) { + return s.transferRepository.Get(id) +} + +func (s *Service) DeleteTransfer(id string) error { + return s.transferRepository.Delete(id) +} + +func (s *Service) AcceptTransfer(id string, authKey string) (Transfer, error) { + transfer, err := s.transferRepository.Get(id) + if err != nil { + return Transfer{}, err + } + if transfer.AuthKey != authKey { + return Transfer{}, ErrTransferNotFound + } + if err := s.transferRepository.Delete(id); err != nil { + return Transfer{}, err + } + + return transfer, nil +} diff --git a/internal/app/volume/type.go b/internal/app/volume/type.go index 9c711bf..c4b7ccd 100644 --- a/internal/app/volume/type.go +++ b/internal/app/volume/type.go @@ -3,6 +3,7 @@ package volume import "errors" var ErrVolumeTypeNotFound = errors.New("volume type not found") +var ErrExtraSpecNotFound = errors.New("extra spec not found") func (s *Service) ListVolumeTypes() []VolumeType { volumeTypes := make([]VolumeType, 0, len(s.volumeTypes)) @@ -20,3 +21,103 @@ func (s *Service) GetVolumeType(id string) (VolumeType, error) { return VolumeType{}, ErrVolumeTypeNotFound } + +func (s *Service) ListExtraSpecs(volumeTypeID string) (map[string]string, error) { + volumeType, err := s.GetVolumeType(volumeTypeID) + if err != nil { + return nil, err + } + + return copyStringMap(volumeType.ExtraSpecs), nil +} + +func (s *Service) GetExtraSpec( + volumeTypeID string, + key string, +) (map[string]string, error) { + extraSpecs, err := s.ListExtraSpecs(volumeTypeID) + if err != nil { + return nil, err + } + + value, ok := extraSpecs[key] + if !ok { + return nil, ErrExtraSpecNotFound + } + + return map[string]string{key: value}, nil +} + +func (s *Service) CreateExtraSpecs( + volumeTypeID string, + extraSpecs map[string]string, +) (map[string]string, error) { + volumeType, index, err := s.findVolumeType(volumeTypeID) + if err != nil { + return nil, err + } + if volumeType.ExtraSpecs == nil { + volumeType.ExtraSpecs = map[string]string{} + } + + for key, value := range extraSpecs { + volumeType.ExtraSpecs[key] = value + } + s.volumeTypes[index] = volumeType + + return copyStringMap(volumeType.ExtraSpecs), nil +} + +func (s *Service) UpdateExtraSpec( + volumeTypeID string, + extraSpec map[string]string, +) (map[string]string, error) { + volumeType, index, err := s.findVolumeType(volumeTypeID) + if err != nil { + return nil, err + } + if volumeType.ExtraSpecs == nil { + volumeType.ExtraSpecs = map[string]string{} + } + + for key, value := range extraSpec { + volumeType.ExtraSpecs[key] = value + s.volumeTypes[index] = volumeType + return map[string]string{key: value}, nil + } + + return nil, ErrExtraSpecNotFound +} + +func (s *Service) DeleteExtraSpec(volumeTypeID string, key string) error { + volumeType, index, err := s.findVolumeType(volumeTypeID) + if err != nil { + return err + } + if _, ok := volumeType.ExtraSpecs[key]; !ok { + return ErrExtraSpecNotFound + } + + delete(volumeType.ExtraSpecs, key) + s.volumeTypes[index] = volumeType + return nil +} + +func (s *Service) findVolumeType(id string) (VolumeType, int, error) { + for index, volumeType := range s.volumeTypes { + if volumeType.ID == id { + return volumeType, index, nil + } + } + + return VolumeType{}, 0, ErrVolumeTypeNotFound +} + +func copyStringMap(values map[string]string) map[string]string { + copied := map[string]string{} + for key, value := range values { + copied[key] = value + } + + return copied +} diff --git a/internal/app/volume/types.go b/internal/app/volume/types.go index 33403a1..3f8d7d2 100644 --- a/internal/app/volume/types.go +++ b/internal/app/volume/types.go @@ -30,3 +30,153 @@ type VolumeType struct { ExtraSpecs map[string]string IsPublic bool } + +type AvailabilityZone struct { + Name string + Available bool +} + +type Limits struct { + MaxTotalVolumes int + MaxTotalSnapshots int + MaxTotalVolumeGigabytes int + MaxTotalBackups int + MaxTotalBackupGigabytes int + TotalVolumesUsed int + TotalGigabytesUsed int + TotalSnapshotsUsed int + TotalBackupsUsed int + TotalBackupGigabytesUsed int +} + +type QuotaSet struct { + ID string + Volumes int + Snapshots int + Gigabytes int + PerVolumeGigabytes int + Backups int + BackupGigabytes int + Groups int +} + +type UpdateQuotaSet struct { + Volumes *int + Snapshots *int + Gigabytes *int + PerVolumeGigabytes *int + Backups *int + BackupGigabytes *int + Groups *int +} + +type QuotaUsageSet struct { + ID string + Volumes QuotaUsage + Snapshots QuotaUsage + Gigabytes QuotaUsage + PerVolumeGigabytes QuotaUsage + Backups QuotaUsage + BackupGigabytes QuotaUsage + Groups QuotaUsage +} + +type QuotaUsage struct { + InUse int + Reserved int + Limit int + Allocated int +} + +type Snapshot struct { + ID string + Name string + Description string + VolumeID string + Status string + Size int + Metadata map[string]string + Progress string + ProjectID string + UserID string + CreatedAt string + UpdatedAt string +} + +type CreateSnapshot struct { + Name string + Description string + VolumeID string + Force bool + Metadata map[string]string +} + +type Transfer struct { + ID string + AuthKey string + Name string + VolumeID string + CreatedAt string + Links []map[string]string +} + +type CreateTransfer struct { + Name string + VolumeID string +} + +type Backup struct { + ID string + Name string + Description string + VolumeID string + SnapshotID string + Status string + Size int + ObjectCount int + Container string + HasDependentBackups bool + FailReason string + IsIncremental bool + ProjectID string + Metadata map[string]string + AvailabilityZone string + CreatedAt string + UpdatedAt string + DataTimestamp string +} + +type CreateBackup struct { + Name string + Description string + VolumeID string + Force bool + Metadata map[string]string + Container string + Incremental bool + SnapshotID string + AvailabilityZone string +} + +type Attachment struct { + ID string + VolumeID string + Instance string + AttachedAt string + DetachedAt string + Status string + AttachMode string + ConnectionInfo map[string]any + Connector map[string]any +} + +type CreateAttachment struct { + VolumeID string + InstanceID string + Connector map[string]any + Mode string +} + +type UpdateAttachment struct { + Connector map[string]any +} diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go index d60a3af..6e05f71 100644 --- a/internal/app/volume/volume_test.go +++ b/internal/app/volume/volume_test.go @@ -23,6 +23,11 @@ func (s *VolumeSuite) TestCreateVolumeUsesInjectedClock() { now := time.Date(2026, 6, 23, 8, 30, 0, 123456000, time.UTC) service := volume.NewServiceWithRuntime( storevolume.NewMemoryRepository(), + storevolume.NewMemorySnapshotRepository(), + storevolume.NewMemoryTransferRepository(), + storevolume.NewMemoryBackupRepository(), + storevolume.NewMemoryAttachmentRepository(), + storevolume.NewMemoryQuotaRepository(), clock.Fixed(now), idgen.Random(), ) @@ -39,6 +44,11 @@ func (s *VolumeSuite) TestCreateVolumeUsesInjectedClock() { func (s *VolumeSuite) TestCreateVolumeUsesInjectedIDGenerator() { service := volume.NewServiceWithRuntime( storevolume.NewMemoryRepository(), + storevolume.NewMemorySnapshotRepository(), + storevolume.NewMemoryTransferRepository(), + storevolume.NewMemoryBackupRepository(), + storevolume.NewMemoryAttachmentRepository(), + storevolume.NewMemoryQuotaRepository(), clock.Fixed(time.Time{}), idgen.Fixed("volume-id"), ) @@ -55,6 +65,11 @@ func (s *VolumeSuite) TestGetVolumeMakesCreatedVolumeAvailable() { now := time.Date(2026, 6, 23, 8, 30, 0, 123456000, time.UTC) service := volume.NewServiceWithRuntime( storevolume.NewMemoryRepository(), + storevolume.NewMemorySnapshotRepository(), + storevolume.NewMemoryTransferRepository(), + storevolume.NewMemoryBackupRepository(), + storevolume.NewMemoryAttachmentRepository(), + storevolume.NewMemoryQuotaRepository(), clock.Fixed(now), idgen.Fixed("volume-id"), ) @@ -74,6 +89,11 @@ func (s *VolumeSuite) TestGetVolumeMakesCreatedVolumeAvailable() { func (s *VolumeSuite) TestResetClearsVolumes() { service := volume.NewServiceWithRuntime( storevolume.NewMemoryRepository(), + storevolume.NewMemorySnapshotRepository(), + storevolume.NewMemoryTransferRepository(), + storevolume.NewMemoryBackupRepository(), + storevolume.NewMemoryAttachmentRepository(), + storevolume.NewMemoryQuotaRepository(), clock.Fixed(time.Time{}), idgen.Fixed("volume-id"), ) @@ -81,8 +101,28 @@ func (s *VolumeSuite) TestResetClearsVolumes() { Size: 1, Name: "database", }) + service.CreateSnapshot(volume.CreateSnapshot{ + Name: "database-snapshot", + VolumeID: "vol-volume-id", + }) + service.CreateTransfer(volume.CreateTransfer{ + Name: "database-transfer", + VolumeID: "vol-volume-id", + }) + service.CreateBackup(volume.CreateBackup{ + Name: "database-backup", + VolumeID: "vol-volume-id", + }) + service.CreateAttachment(volume.CreateAttachment{ + VolumeID: "vol-volume-id", + InstanceID: "server-id", + }) service.Reset() s.Assert().Empty(service.List()) + s.Assert().Empty(service.ListSnapshots()) + s.Assert().Empty(service.ListTransfers()) + s.Assert().Empty(service.ListBackups()) + s.Assert().Empty(service.ListAttachments()) } diff --git a/internal/store/volume/memory_attachment_repository.go b/internal/store/volume/memory_attachment_repository.go new file mode 100644 index 0000000..9e98542 --- /dev/null +++ b/internal/store/volume/memory_attachment_repository.go @@ -0,0 +1,99 @@ +package volume + +import ( + "sync" + + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" +) + +type MemoryAttachmentRepository struct { + mu sync.RWMutex + ids []string + attachments map[string]appvolume.Attachment +} + +func NewMemoryAttachmentRepository() *MemoryAttachmentRepository { + return &MemoryAttachmentRepository{ + ids: []string{}, + attachments: map[string]appvolume.Attachment{}, + } +} + +func (r *MemoryAttachmentRepository) Create( + attachment appvolume.Attachment, +) appvolume.Attachment { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, attachment.ID) + r.attachments[attachment.ID] = attachment + + return attachment +} + +func (r *MemoryAttachmentRepository) List() []appvolume.Attachment { + r.mu.RLock() + defer r.mu.RUnlock() + + attachments := make([]appvolume.Attachment, 0, len(r.ids)) + for _, id := range r.ids { + attachments = append(attachments, r.attachments[id]) + } + + return attachments +} + +func (r *MemoryAttachmentRepository) Get( + id string, +) (appvolume.Attachment, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + attachment, ok := r.attachments[id] + if !ok { + return appvolume.Attachment{}, appvolume.ErrAttachmentNotFound + } + + return attachment, nil +} + +func (r *MemoryAttachmentRepository) Update( + attachment appvolume.Attachment, +) (appvolume.Attachment, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.attachments[attachment.ID]; !ok { + return appvolume.Attachment{}, appvolume.ErrAttachmentNotFound + } + + r.attachments[attachment.ID] = attachment + return attachment, nil +} + +func (r *MemoryAttachmentRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.attachments[id]; !ok { + return appvolume.ErrAttachmentNotFound + } + + delete(r.attachments, id) + for index, currentID := range r.ids { + if currentID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryAttachmentRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.attachments = map[string]appvolume.Attachment{} +} diff --git a/internal/store/volume/memory_backup_repository.go b/internal/store/volume/memory_backup_repository.go new file mode 100644 index 0000000..0528aff --- /dev/null +++ b/internal/store/volume/memory_backup_repository.go @@ -0,0 +1,81 @@ +package volume + +import ( + "sync" + + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" +) + +type MemoryBackupRepository struct { + mu sync.RWMutex + ids []string + backups map[string]appvolume.Backup +} + +func NewMemoryBackupRepository() *MemoryBackupRepository { + return &MemoryBackupRepository{ + ids: []string{}, + backups: map[string]appvolume.Backup{}, + } +} + +func (r *MemoryBackupRepository) Create(backup appvolume.Backup) appvolume.Backup { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, backup.ID) + r.backups[backup.ID] = backup + + return backup +} + +func (r *MemoryBackupRepository) List() []appvolume.Backup { + r.mu.RLock() + defer r.mu.RUnlock() + + backups := make([]appvolume.Backup, 0, len(r.ids)) + for _, id := range r.ids { + backups = append(backups, r.backups[id]) + } + + return backups +} + +func (r *MemoryBackupRepository) Get(id string) (appvolume.Backup, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + backup, ok := r.backups[id] + if !ok { + return appvolume.Backup{}, appvolume.ErrBackupNotFound + } + + return backup, nil +} + +func (r *MemoryBackupRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.backups[id]; !ok { + return appvolume.ErrBackupNotFound + } + + delete(r.backups, id) + for index, currentID := range r.ids { + if currentID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryBackupRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.backups = map[string]appvolume.Backup{} +} diff --git a/internal/store/volume/memory_quota_repository.go b/internal/store/volume/memory_quota_repository.go new file mode 100644 index 0000000..ee1138c --- /dev/null +++ b/internal/store/volume/memory_quota_repository.go @@ -0,0 +1,59 @@ +package volume + +import ( + "sync" + + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" +) + +type MemoryQuotaRepository struct { + mu sync.RWMutex + quotaSets map[string]appvolume.QuotaSet +} + +func NewMemoryQuotaRepository() *MemoryQuotaRepository { + return &MemoryQuotaRepository{ + quotaSets: map[string]appvolume.QuotaSet{}, + } +} + +func (r *MemoryQuotaRepository) Get( + projectID string, +) (appvolume.QuotaSet, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + quotaSet, ok := r.quotaSets[projectID] + if !ok { + return appvolume.QuotaSet{}, appvolume.ErrQuotaSetNotFound + } + + return quotaSet, nil +} + +func (r *MemoryQuotaRepository) Save( + quotaSet appvolume.QuotaSet, +) appvolume.QuotaSet { + r.mu.Lock() + defer r.mu.Unlock() + + r.quotaSets[quotaSet.ID] = quotaSet + + return quotaSet +} + +func (r *MemoryQuotaRepository) Delete(projectID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.quotaSets, projectID) + + return nil +} + +func (r *MemoryQuotaRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.quotaSets = map[string]appvolume.QuotaSet{} +} diff --git a/internal/store/volume/memory_snapshot_repository.go b/internal/store/volume/memory_snapshot_repository.go new file mode 100644 index 0000000..fe8e29d --- /dev/null +++ b/internal/store/volume/memory_snapshot_repository.go @@ -0,0 +1,83 @@ +package volume + +import ( + "sync" + + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" +) + +type MemorySnapshotRepository struct { + mu sync.RWMutex + ids []string + snapshots map[string]appvolume.Snapshot +} + +func NewMemorySnapshotRepository() *MemorySnapshotRepository { + return &MemorySnapshotRepository{ + ids: []string{}, + snapshots: map[string]appvolume.Snapshot{}, + } +} + +func (r *MemorySnapshotRepository) Create( + snapshot appvolume.Snapshot, +) appvolume.Snapshot { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, snapshot.ID) + r.snapshots[snapshot.ID] = snapshot + + return snapshot +} + +func (r *MemorySnapshotRepository) List() []appvolume.Snapshot { + r.mu.RLock() + defer r.mu.RUnlock() + + snapshots := make([]appvolume.Snapshot, 0, len(r.ids)) + for _, id := range r.ids { + snapshots = append(snapshots, r.snapshots[id]) + } + + return snapshots +} + +func (r *MemorySnapshotRepository) Get(id string) (appvolume.Snapshot, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + snapshot, ok := r.snapshots[id] + if !ok { + return appvolume.Snapshot{}, appvolume.ErrSnapshotNotFound + } + + return snapshot, nil +} + +func (r *MemorySnapshotRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.snapshots[id]; !ok { + return appvolume.ErrSnapshotNotFound + } + + delete(r.snapshots, id) + for index, currentID := range r.ids { + if currentID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemorySnapshotRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.snapshots = map[string]appvolume.Snapshot{} +} diff --git a/internal/store/volume/memory_transfer_repository.go b/internal/store/volume/memory_transfer_repository.go new file mode 100644 index 0000000..4241ebd --- /dev/null +++ b/internal/store/volume/memory_transfer_repository.go @@ -0,0 +1,83 @@ +package volume + +import ( + "sync" + + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" +) + +type MemoryTransferRepository struct { + mu sync.RWMutex + ids []string + transfers map[string]appvolume.Transfer +} + +func NewMemoryTransferRepository() *MemoryTransferRepository { + return &MemoryTransferRepository{ + ids: []string{}, + transfers: map[string]appvolume.Transfer{}, + } +} + +func (r *MemoryTransferRepository) Create( + transfer appvolume.Transfer, +) appvolume.Transfer { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, transfer.ID) + r.transfers[transfer.ID] = transfer + + return transfer +} + +func (r *MemoryTransferRepository) List() []appvolume.Transfer { + r.mu.RLock() + defer r.mu.RUnlock() + + transfers := make([]appvolume.Transfer, 0, len(r.ids)) + for _, id := range r.ids { + transfers = append(transfers, r.transfers[id]) + } + + return transfers +} + +func (r *MemoryTransferRepository) Get(id string) (appvolume.Transfer, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + transfer, ok := r.transfers[id] + if !ok { + return appvolume.Transfer{}, appvolume.ErrTransferNotFound + } + + return transfer, nil +} + +func (r *MemoryTransferRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.transfers[id]; !ok { + return appvolume.ErrTransferNotFound + } + + delete(r.transfers, id) + for index, currentID := range r.ids { + if currentID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryTransferRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.transfers = map[string]appvolume.Transfer{} +}