diff --git a/internal/api/image/data.go b/internal/api/image/data.go new file mode 100644 index 0000000..c7d539c --- /dev/null +++ b/internal/api/image/data.go @@ -0,0 +1,92 @@ +package image + +import ( + "errors" + "io" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/go-chi/chi/v5" +) + +func (h Handler) uploadImageData(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid image data") + return + } + + err = h.service.UploadData(chi.URLParam(r, "image_id"), data) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image data upload failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h Handler) stageImageData(w http.ResponseWriter, r *http.Request) { + data, err := io.ReadAll(r.Body) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid image data") + return + } + + err = h.service.StageData(chi.URLParam(r, "image_id"), data) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image data stage failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (h Handler) importImageData(w http.ResponseWriter, r *http.Request) { + err := h.service.ImportData(chi.URLParam(r, "image_id")) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image import failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} + +func (h Handler) getImportInfo(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, importInfoResponse{ + ImportMethods: importMethodsDocument{ + Description: "Import methods available.", + Type: "array", + Value: []string{ + "glance-direct", + }, + }, + }) +} + +func (h Handler) downloadImageData(w http.ResponseWriter, r *http.Request) { + data, err := h.service.DownloadData(chi.URLParam(r, "image_id")) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image data not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image data download failed") + return + } + + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(data) +} diff --git a/internal/api/image/data_test.go b/internal/api/image/data_test.go new file mode 100644 index 0000000..c7ae079 --- /dev/null +++ b/internal/api/image/data_test.go @@ -0,0 +1,75 @@ +package image_test + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/image" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/imagedata" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/stretchr/testify/suite" +) + +type DataSuite struct { + suite.Suite + server *httptest.Server +} + +func TestDataSuite(t *testing.T) { + suite.Run(t, new(DataSuite)) +} + +func (s *DataSuite) SetupTest() { + s.server = httptest.NewServer( + image.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *DataSuite) TearDownTest() { + s.server.Close() +} + +func (s *DataSuite) TestUploadAndDownloadImageData() { + created := s.createImage("ubuntu") + + err := imagedata.Upload( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + strings.NewReader("image-data"), + ).ExtractErr() + s.Require().NoError(err) + + reader, err := imagedata.Download( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(reader) + defer reader.Close() + + data, err := io.ReadAll(reader) + s.Require().NoError(err) + + s.Assert().Equal("image-data", string(data)) +} + +func (s *DataSuite) createImage(name string) *images.Image { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + images.CreateOpts{ + Name: name, + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/image/image_dto.go b/internal/api/image/image_dto.go index 72722f3..f0adc05 100644 --- a/internal/api/image/image_dto.go +++ b/internal/api/image/image_dto.go @@ -37,6 +37,7 @@ type imageDocument struct { Protected bool `json:"protected"` Visibility string `json:"visibility"` Tags []string `json:"tags"` + SizeBytes int64 `json:"size"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` File string `json:"file"` @@ -64,6 +65,7 @@ func toImageDocument(image appimage.Image) imageDocument { Protected: image.Protected, Visibility: image.Visibility, Tags: image.Tags, + SizeBytes: image.SizeBytes, CreatedAt: image.CreatedAt, UpdatedAt: image.UpdatedAt, File: "/v2/images/" + image.ID + "/file", diff --git a/internal/api/image/import_dto.go b/internal/api/image/import_dto.go new file mode 100644 index 0000000..e6253ce --- /dev/null +++ b/internal/api/image/import_dto.go @@ -0,0 +1,11 @@ +package image + +type importInfoResponse struct { + ImportMethods importMethodsDocument `json:"import-methods"` +} + +type importMethodsDocument struct { + Description string `json:"description"` + Type string `json:"type"` + Value []string `json:"value"` +} diff --git a/internal/api/image/import_test.go b/internal/api/image/import_test.go new file mode 100644 index 0000000..f303863 --- /dev/null +++ b/internal/api/image/import_test.go @@ -0,0 +1,78 @@ +package image_test + +import ( + "bytes" + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/image" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/imagedata" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/imageimport" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/stretchr/testify/suite" +) + +type ImportSuite struct { + suite.Suite + server *httptest.Server +} + +func TestImportSuite(t *testing.T) { + suite.Run(t, new(ImportSuite)) +} + +func (s *ImportSuite) SetupTest() { + s.server = httptest.NewServer( + image.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ImportSuite) TearDownTest() { + s.server.Close() +} + +func (s *ImportSuite) TestGetImportInfo() { + client := testhelper.ServiceClient(s.server.URL) + + info, err := imageimport.Get(context.Background(), client).Extract() + s.Require().NoError(err) + + s.Assert().Contains(info.ImportMethods.Value, "glance-direct") +} + +func (s *ImportSuite) TestStageAndImportImageData() { + client := testhelper.ServiceClient(s.server.URL) + + created, err := images.Create( + context.Background(), + client, + images.CreateOpts{Name: "ubuntu"}, + ).Extract() + s.Require().NoError(err) + + err = imagedata.Stage( + context.Background(), + client, + created.ID, + bytes.NewBufferString("image-bytes"), + ).ExtractErr() + s.Require().NoError(err) + + staged, err := images.Get(context.Background(), client, created.ID).Extract() + s.Require().NoError(err) + s.Assert().Equal(images.ImageStatus("uploading"), staged.Status) + + err = imageimport.Create( + context.Background(), + client, + created.ID, + imageimport.CreateOpts{Name: imageimport.GlanceDirectMethod}, + ).ExtractErr() + s.Require().NoError(err) + + imported, err := images.Get(context.Background(), client, created.ID).Extract() + s.Require().NoError(err) + s.Assert().Equal(images.ImageStatusActive, imported.Status) +} diff --git a/internal/api/image/member.go b/internal/api/image/member.go new file mode 100644 index 0000000..31f7bf7 --- /dev/null +++ b/internal/api/image/member.go @@ -0,0 +1,108 @@ +package image + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listMembers(w http.ResponseWriter, r *http.Request) { + members, err := h.service.ListMembers(chi.URLParam(r, "image_id")) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image member lookup failed") + return + } + + respond.JSON(w, http.StatusOK, memberListResponse{ + Members: toMemberDocuments(members), + }) +} + +func (h Handler) createMember(w http.ResponseWriter, r *http.Request) { + var request createMemberRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + member, err := h.service.CreateMember( + chi.URLParam(r, "image_id"), + request.Member, + ) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image member create failed") + return + } + + respond.JSON(w, http.StatusOK, toMemberDocument(member)) +} + +func (h Handler) getMember(w http.ResponseWriter, r *http.Request) { + member, err := h.service.GetMember( + chi.URLParam(r, "image_id"), + chi.URLParam(r, "member_id"), + ) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image member not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image member lookup failed") + return + } + + respond.JSON(w, http.StatusOK, toMemberDocument(member)) +} + +func (h Handler) updateMember(w http.ResponseWriter, r *http.Request) { + var request updateMemberRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + member, err := h.service.UpdateMember( + chi.URLParam(r, "image_id"), + chi.URLParam(r, "member_id"), + request.Status, + ) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image member not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image member update failed") + return + } + + respond.JSON(w, http.StatusOK, toMemberDocument(member)) +} + +func (h Handler) deleteMember(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteMember( + chi.URLParam(r, "image_id"), + chi.URLParam(r, "member_id"), + ) + if errors.Is(err, appimage.ErrImageNotFound) { + respond.Error(w, http.StatusNotFound, "image member not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "image member delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/image/member_dto.go b/internal/api/image/member_dto.go new file mode 100644 index 0000000..d3d0c5a --- /dev/null +++ b/internal/api/image/member_dto.go @@ -0,0 +1,44 @@ +package image + +import appimage "github.com/JSYoo5B/SandStack/internal/app/image" + +type createMemberRequest struct { + Member string `json:"member"` +} + +type updateMemberRequest struct { + Status string `json:"status"` +} + +type memberListResponse struct { + Members []memberDocument `json:"members"` +} + +type memberDocument struct { + ImageID string `json:"image_id"` + MemberID string `json:"member_id"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Schema string `json:"schema"` +} + +func toMemberDocuments(members []appimage.Member) []memberDocument { + documents := make([]memberDocument, 0, len(members)) + for _, member := range members { + documents = append(documents, toMemberDocument(member)) + } + + return documents +} + +func toMemberDocument(member appimage.Member) memberDocument { + return memberDocument{ + ImageID: member.ImageID, + MemberID: member.MemberID, + Status: member.Status, + CreatedAt: member.CreatedAt, + UpdatedAt: member.UpdatedAt, + Schema: "/v2/schemas/member", + } +} diff --git a/internal/api/image/member_test.go b/internal/api/image/member_test.go new file mode 100644 index 0000000..d2fe0a5 --- /dev/null +++ b/internal/api/image/member_test.go @@ -0,0 +1,133 @@ +package image_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/image" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/members" + "github.com/stretchr/testify/suite" +) + +type MemberSuite struct { + suite.Suite + server *httptest.Server +} + +func TestMemberSuite(t *testing.T) { + suite.Run(t, new(MemberSuite)) +} + +func (s *MemberSuite) SetupTest() { + s.server = httptest.NewServer( + image.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *MemberSuite) TearDownTest() { + s.server.Close() +} + +func (s *MemberSuite) TestCreateMemberThenListMembers() { + image := s.createImage("ubuntu") + + created := s.createMember(image.ID, "project-1") + list := s.listMembers(image.ID) + + s.Assert().Equal(image.ID, created.ImageID) + s.Assert().Equal("project-1", created.MemberID) + s.Assert().Equal("pending", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal("project-1", list[0].MemberID) +} + +func (s *MemberSuite) TestGetAndUpdateMember() { + image := s.createImage("ubuntu") + created := s.createMember(image.ID, "project-1") + + found, err := members.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + image.ID, + created.MemberID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + updated, err := members.Update( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + image.ID, + created.MemberID, + members.UpdateOpts{Status: "accepted"}, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(updated) + + s.Assert().Equal("project-1", found.MemberID) + s.Assert().Equal("accepted", updated.Status) +} + +func (s *MemberSuite) TestDeleteMember() { + image := s.createImage("ubuntu") + created := s.createMember(image.ID, "project-1") + + err := members.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + image.ID, + created.MemberID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listMembers(image.ID) + + s.Assert().Empty(list) +} + +func (s *MemberSuite) listMembers(imageID string) []members.Member { + pages, err := members.List( + testhelper.ServiceClient(s.server.URL), + imageID, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := members.ExtractMembers(pages) + s.Require().NoError(err) + + return list +} + +func (s *MemberSuite) createMember( + imageID string, + memberID string, +) *members.Member { + created, err := members.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + imageID, + memberID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *MemberSuite) createImage(name string) *images.Image { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + images.CreateOpts{ + Name: name, + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/image/router.go b/internal/api/image/router.go index 9d9e1f3..c083632 100644 --- a/internal/api/image/router.go +++ b/internal/api/image/router.go @@ -32,6 +32,9 @@ func NewHandler(cfg config.Config) Handler { cfg, appimage.NewServiceWithRuntime( storeimage.NewMemoryRepository(), + storeimage.NewMemoryDataRepository(), + storeimage.NewMemoryMemberRepository(), + storeimage.NewMemoryTaskRepository(), clock.Wall(), idgen.Random(), ), @@ -56,6 +59,19 @@ func (h Handler) Router() http.Handler { router.Get("/images/{image_id}", h.getImage) router.Patch("/images/{image_id}", h.updateImage) router.Delete("/images/{image_id}", h.deleteImage) + router.Put("/images/{image_id}/file", h.uploadImageData) + router.Get("/images/{image_id}/file", h.downloadImageData) + router.Put("/images/{image_id}/stage", h.stageImageData) + router.Post("/images/{image_id}/import", h.importImageData) + router.Get("/info/import", h.getImportInfo) + router.Get("/tasks", h.listTasks) + router.Post("/tasks", h.createTask) + router.Get("/tasks/{task_id}", h.getTask) + router.Get("/images/{image_id}/members", h.listMembers) + router.Post("/images/{image_id}/members", h.createMember) + router.Get("/images/{image_id}/members/{member_id}", h.getMember) + router.Put("/images/{image_id}/members/{member_id}", h.updateMember) + router.Delete("/images/{image_id}/members/{member_id}", h.deleteMember) return router } diff --git a/internal/api/image/task.go b/internal/api/image/task.go new file mode 100644 index 0000000..1048dbf --- /dev/null +++ b/internal/api/image/task.go @@ -0,0 +1,44 @@ +package image + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listTasks(w http.ResponseWriter, r *http.Request) { + respond.JSON(w, http.StatusOK, taskListResponse{ + Tasks: toTaskDocuments(h.service.ListTasks()), + Next: "", + }) +} + +func (h Handler) createTask(w http.ResponseWriter, r *http.Request) { + var request createTaskRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + task := h.service.CreateTask(request.createTask()) + + respond.JSON(w, http.StatusCreated, toTaskDocument(task)) +} + +func (h Handler) getTask(w http.ResponseWriter, r *http.Request) { + task, err := h.service.GetTask(chi.URLParam(r, "task_id")) + if errors.Is(err, appimage.ErrTaskNotFound) { + respond.Error(w, http.StatusNotFound, "task not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "task lookup failed") + return + } + + respond.JSON(w, http.StatusOK, toTaskDocument(task)) +} diff --git a/internal/api/image/task_dto.go b/internal/api/image/task_dto.go new file mode 100644 index 0000000..a0e43f0 --- /dev/null +++ b/internal/api/image/task_dto.go @@ -0,0 +1,61 @@ +package image + +import appimage "github.com/JSYoo5B/SandStack/internal/app/image" + +type createTaskRequest struct { + Type string `json:"type"` + Input map[string]any `json:"input"` +} + +type taskListResponse struct { + Tasks []taskDocument `json:"tasks"` + Next string `json:"next"` +} + +type taskDocument struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Input map[string]any `json:"input"` + Result map[string]any `json:"result"` + Owner string `json:"owner"` + Message string `json:"message"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Self string `json:"self"` + Schema string `json:"schema"` +} + +func (r createTaskRequest) createTask() appimage.CreateTask { + return appimage.CreateTask{ + Type: r.Type, + Input: r.Input, + } +} + +func toTaskDocuments(tasks []appimage.Task) []taskDocument { + documents := make([]taskDocument, 0, len(tasks)) + for _, task := range tasks { + documents = append(documents, toTaskDocument(task)) + } + + return documents +} + +func toTaskDocument(task appimage.Task) taskDocument { + return taskDocument{ + ID: task.ID, + Type: task.Type, + Status: task.Status, + Input: task.Input, + Result: task.Result, + Owner: task.Owner, + Message: task.Message, + ExpiresAt: task.ExpiresAt, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + Self: "/v2/tasks/" + task.ID, + Schema: "/v2/schemas/task", + } +} diff --git a/internal/api/image/task_test.go b/internal/api/image/task_test.go new file mode 100644 index 0000000..b68892b --- /dev/null +++ b/internal/api/image/task_test.go @@ -0,0 +1,61 @@ +package image_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/image" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/tasks" + "github.com/stretchr/testify/suite" +) + +type TaskSuite struct { + suite.Suite + server *httptest.Server +} + +func TestTaskSuite(t *testing.T) { + suite.Run(t, new(TaskSuite)) +} + +func (s *TaskSuite) SetupTest() { + s.server = httptest.NewServer( + image.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *TaskSuite) TearDownTest() { + s.server.Close() +} + +func (s *TaskSuite) TestCreateGetAndListTask() { + client := testhelper.ServiceClient(s.server.URL) + + created, err := tasks.Create( + context.Background(), + client, + tasks.CreateOpts{ + Type: "import", + Input: map[string]any{ + "image_id": "image-1", + }, + }, + ).Extract() + s.Require().NoError(err) + + found, err := tasks.Get(context.Background(), client, created.ID).Extract() + s.Require().NoError(err) + + pages, err := tasks.List(client, nil).AllPages(context.Background()) + s.Require().NoError(err) + + listed, err := tasks.ExtractTasks(pages) + s.Require().NoError(err) + + s.Assert().Equal("import", found.Type) + s.Assert().Equal(tasks.TaskStatusPending, tasks.TaskStatus(found.Status)) + s.Require().Len(listed, 1) + s.Assert().Equal(created.ID, listed[0].ID) +} diff --git a/internal/api/router.go b/internal/api/router.go index 86af9b2..0c62ab8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -54,6 +54,9 @@ func NewRouter(cfg config.Config) http.Handler { ) imageService := appimage.NewServiceWithRuntime( storeimage.NewMemoryRepository(), + storeimage.NewMemoryDataRepository(), + storeimage.NewMemoryMemberRepository(), + storeimage.NewMemoryTaskRepository(), clock.Wall(), idgen.Random(), ) diff --git a/internal/app/image/data.go b/internal/app/image/data.go new file mode 100644 index 0000000..b6b7b94 --- /dev/null +++ b/internal/app/image/data.go @@ -0,0 +1,63 @@ +package image + +import "time" + +func (s *Service) UploadData(id string, data []byte) error { + image, err := s.repository.Get(id) + if err != nil { + return err + } + + now := s.clock.Now().UTC() + image.Status = "active" + image.SizeBytes = int64(len(data)) + image.UpdatedAt = now.Format(time.RFC3339) + + if _, err := s.repository.Update(image); err != nil { + return err + } + s.dataRepository.Put(id, data) + + return nil +} + +func (s *Service) StageData(id string, data []byte) error { + image, err := s.repository.Get(id) + if err != nil { + return err + } + + now := s.clock.Now().UTC() + image.Status = "uploading" + image.SizeBytes = int64(len(data)) + image.UpdatedAt = now.Format(time.RFC3339) + + if _, err := s.repository.Update(image); err != nil { + return err + } + s.dataRepository.Put(id, data) + + return nil +} + +func (s *Service) ImportData(id string) error { + image, err := s.repository.Get(id) + if err != nil { + return err + } + + now := s.clock.Now().UTC() + image.Status = "active" + image.UpdatedAt = now.Format(time.RFC3339) + + _, err = s.repository.Update(image) + return err +} + +func (s *Service) DownloadData(id string) ([]byte, error) { + if _, err := s.repository.Get(id); err != nil { + return nil, err + } + + return s.dataRepository.Get(id) +} diff --git a/internal/app/image/member.go b/internal/app/image/member.go new file mode 100644 index 0000000..199e54f --- /dev/null +++ b/internal/app/image/member.go @@ -0,0 +1,60 @@ +package image + +import "time" + +func (s *Service) CreateMember(imageID string, memberID string) (Member, error) { + if _, err := s.repository.Get(imageID); err != nil { + return Member{}, err + } + + now := s.clock.Now().UTC().Format(time.RFC3339) + member := Member{ + ImageID: imageID, + MemberID: memberID, + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + } + + return s.memberRepository.Create(member), nil +} + +func (s *Service) ListMembers(imageID string) ([]Member, error) { + if _, err := s.repository.Get(imageID); err != nil { + return nil, err + } + + return s.memberRepository.List(imageID), nil +} + +func (s *Service) GetMember(imageID string, memberID string) (Member, error) { + if _, err := s.repository.Get(imageID); err != nil { + return Member{}, err + } + + return s.memberRepository.Get(imageID, memberID) +} + +func (s *Service) UpdateMember( + imageID string, + memberID string, + status string, +) (Member, error) { + member, err := s.GetMember(imageID, memberID) + if err != nil { + return Member{}, err + } + + member.Status = status + member.UpdatedAt = s.clock.Now().UTC().Format(time.RFC3339) + + return s.memberRepository.Update(member) +} + +func (s *Service) DeleteMember(imageID string, memberID string) error { + if _, err := s.repository.Get(imageID); err != nil { + return err + } + + return s.memberRepository.Delete(imageID, memberID) +} diff --git a/internal/app/image/repository.go b/internal/app/image/repository.go index f4a8324..3f77838 100644 --- a/internal/app/image/repository.go +++ b/internal/app/image/repository.go @@ -8,3 +8,26 @@ type Repository interface { Delete(id string) error Reset() } + +type DataRepository interface { + Put(id string, data []byte) + Get(id string) ([]byte, error) + Delete(id string) + Reset() +} + +type MemberRepository interface { + Create(member Member) Member + List(imageID string) []Member + Get(imageID string, memberID string) (Member, error) + Update(member Member) (Member, error) + Delete(imageID string, memberID string) error + Reset() +} + +type TaskRepository interface { + Create(task Task) Task + List() []Task + Get(id string) (Task, error) + Reset() +} diff --git a/internal/app/image/service.go b/internal/app/image/service.go index 91b4815..f18581f 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -9,22 +9,32 @@ import ( ) var ErrImageNotFound = errors.New("image not found") +var ErrTaskNotFound = errors.New("task not found") type Service struct { - repository Repository - clock clock.Clock - idGen idgen.Generator + repository Repository + dataRepository DataRepository + memberRepository MemberRepository + taskRepository TaskRepository + clock clock.Clock + idGen idgen.Generator } func NewServiceWithRuntime( repository Repository, + dataRepository DataRepository, + memberRepository MemberRepository, + taskRepository TaskRepository, clock clock.Clock, idGen idgen.Generator, ) *Service { return &Service{ - repository: repository, - clock: clock, - idGen: idGen, + repository: repository, + dataRepository: dataRepository, + memberRepository: memberRepository, + taskRepository: taskRepository, + clock: clock, + idGen: idGen, } } @@ -57,9 +67,16 @@ func (s *Service) Get(id string) (Image, error) { } func (s *Service) Delete(id string) error { - return s.repository.Delete(id) + if err := s.repository.Delete(id); err != nil { + return err + } + s.dataRepository.Delete(id) + return nil } func (s *Service) Reset() { s.repository.Reset() + s.dataRepository.Reset() + s.memberRepository.Reset() + s.taskRepository.Reset() } diff --git a/internal/app/image/service_test.go b/internal/app/image/service_test.go index 911546f..d3b38a6 100644 --- a/internal/app/image/service_test.go +++ b/internal/app/image/service_test.go @@ -23,6 +23,9 @@ func (s *ServiceSuite) TestCreateImageUsesInjectedClock() { now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) service := image.NewServiceWithRuntime( storeimage.NewMemoryRepository(), + storeimage.NewMemoryDataRepository(), + storeimage.NewMemoryMemberRepository(), + storeimage.NewMemoryTaskRepository(), clock.Fixed(now), idgen.Random(), ) @@ -40,6 +43,9 @@ func (s *ServiceSuite) TestCreateImageUsesInjectedClock() { func (s *ServiceSuite) TestCreateImageUsesInjectedIDGenerator() { service := image.NewServiceWithRuntime( storeimage.NewMemoryRepository(), + storeimage.NewMemoryDataRepository(), + storeimage.NewMemoryMemberRepository(), + storeimage.NewMemoryTaskRepository(), clock.Fixed(time.Time{}), idgen.Fixed("image-id"), ) @@ -56,16 +62,31 @@ func (s *ServiceSuite) TestCreateImageUsesInjectedIDGenerator() { func (s *ServiceSuite) TestResetClearsImages() { service := image.NewServiceWithRuntime( storeimage.NewMemoryRepository(), + storeimage.NewMemoryDataRepository(), + storeimage.NewMemoryMemberRepository(), + storeimage.NewMemoryTaskRepository(), clock.Fixed(time.Time{}), idgen.Fixed("image-id"), ) - service.Create(image.CreateImage{ + created := service.Create(image.CreateImage{ Name: "ubuntu", ContainerFormat: "bare", DiskFormat: "qcow2", }) + err := service.UploadData(created.ID, []byte("image-data")) + s.Require().NoError(err) + _, err = service.CreateMember(created.ID, "project-1") + s.Require().NoError(err) + task := service.CreateTask(image.CreateTask{Type: "import"}) service.Reset() s.Assert().Empty(service.List()) + _, err = service.DownloadData(created.ID) + s.ErrorIs(err, image.ErrImageNotFound) + members, err := service.ListMembers(created.ID) + s.ErrorIs(err, image.ErrImageNotFound) + s.Nil(members) + _, err = service.GetTask(task.ID) + s.ErrorIs(err, image.ErrTaskNotFound) } diff --git a/internal/app/image/task.go b/internal/app/image/task.go new file mode 100644 index 0000000..6e3efa4 --- /dev/null +++ b/internal/app/image/task.go @@ -0,0 +1,28 @@ +package image + +import "time" + +func (s *Service) CreateTask(input CreateTask) Task { + now := s.clock.Now().UTC() + task := Task{ + ID: "task-" + s.idGen.Hex(16), + Type: input.Type, + Status: "pending", + Input: input.Input, + Result: map[string]any{}, + Owner: "admin", + ExpiresAt: now.Add(24 * time.Hour).Format(time.RFC3339), + CreatedAt: now.Format(time.RFC3339), + UpdatedAt: now.Format(time.RFC3339), + } + + return s.taskRepository.Create(task) +} + +func (s *Service) ListTasks() []Task { + return s.taskRepository.List() +} + +func (s *Service) GetTask(id string) (Task, error) { + return s.taskRepository.Get(id) +} diff --git a/internal/app/image/types.go b/internal/app/image/types.go index 9eca7d8..c111f55 100644 --- a/internal/app/image/types.go +++ b/internal/app/image/types.go @@ -20,6 +20,33 @@ type Image struct { Protected bool Visibility string Tags []string + SizeBytes int64 CreatedAt string UpdatedAt string } + +type Member struct { + ImageID string + MemberID string + Status string + CreatedAt string + UpdatedAt string +} + +type Task struct { + ID string + Type string + Status string + Input map[string]any + Result map[string]any + Owner string + Message string + ExpiresAt string + CreatedAt string + UpdatedAt string +} + +type CreateTask struct { + Type string + Input map[string]any +} diff --git a/internal/store/image/memory_data_repository.go b/internal/store/image/memory_data_repository.go new file mode 100644 index 0000000..3fbc1f2 --- /dev/null +++ b/internal/store/image/memory_data_repository.go @@ -0,0 +1,55 @@ +package image + +import ( + "sync" + + appimage "github.com/JSYoo5B/SandStack/internal/app/image" +) + +type MemoryDataRepository struct { + mu sync.RWMutex + data map[string][]byte +} + +func NewMemoryDataRepository() *MemoryDataRepository { + return &MemoryDataRepository{ + data: map[string][]byte{}, + } +} + +func (r *MemoryDataRepository) Put(id string, data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + + copied := make([]byte, len(data)) + copy(copied, data) + r.data[id] = copied +} + +func (r *MemoryDataRepository) Get(id string) ([]byte, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + data, ok := r.data[id] + if !ok { + return nil, appimage.ErrImageNotFound + } + + copied := make([]byte, len(data)) + copy(copied, data) + return copied, nil +} + +func (r *MemoryDataRepository) Delete(id string) { + r.mu.Lock() + defer r.mu.Unlock() + + delete(r.data, id) +} + +func (r *MemoryDataRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.data = map[string][]byte{} +} diff --git a/internal/store/image/memory_member_repository.go b/internal/store/image/memory_member_repository.go new file mode 100644 index 0000000..25b7ca4 --- /dev/null +++ b/internal/store/image/memory_member_repository.go @@ -0,0 +1,110 @@ +package image + +import ( + "sync" + + appimage "github.com/JSYoo5B/SandStack/internal/app/image" +) + +type MemoryMemberRepository struct { + mu sync.RWMutex + keys []memberKey + members map[memberKey]appimage.Member +} + +type memberKey struct { + imageID string + memberID string +} + +func NewMemoryMemberRepository() *MemoryMemberRepository { + return &MemoryMemberRepository{ + keys: []memberKey{}, + members: map[memberKey]appimage.Member{}, + } +} + +func (r *MemoryMemberRepository) Create(member appimage.Member) appimage.Member { + r.mu.Lock() + defer r.mu.Unlock() + + key := memberKey{imageID: member.ImageID, memberID: member.MemberID} + if _, ok := r.members[key]; !ok { + r.keys = append(r.keys, key) + } + r.members[key] = member + + return member +} + +func (r *MemoryMemberRepository) List(imageID string) []appimage.Member { + r.mu.RLock() + defer r.mu.RUnlock() + + members := []appimage.Member{} + for _, key := range r.keys { + if key.imageID == imageID { + members = append(members, r.members[key]) + } + } + + return members +} + +func (r *MemoryMemberRepository) Get( + imageID string, + memberID string, +) (appimage.Member, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + member, ok := r.members[memberKey{imageID: imageID, memberID: memberID}] + if !ok { + return appimage.Member{}, appimage.ErrImageNotFound + } + + return member, nil +} + +func (r *MemoryMemberRepository) Update( + member appimage.Member, +) (appimage.Member, error) { + r.mu.Lock() + defer r.mu.Unlock() + + key := memberKey{imageID: member.ImageID, memberID: member.MemberID} + if _, ok := r.members[key]; !ok { + return appimage.Member{}, appimage.ErrImageNotFound + } + r.members[key] = member + + return member, nil +} + +func (r *MemoryMemberRepository) Delete(imageID string, memberID string) error { + r.mu.Lock() + defer r.mu.Unlock() + + key := memberKey{imageID: imageID, memberID: memberID} + if _, ok := r.members[key]; !ok { + return appimage.ErrImageNotFound + } + + delete(r.members, key) + for index, currentKey := range r.keys { + if currentKey == key { + r.keys = append(r.keys[:index], r.keys[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryMemberRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.keys = []memberKey{} + r.members = map[memberKey]appimage.Member{} +} diff --git a/internal/store/image/memory_task_repository.go b/internal/store/image/memory_task_repository.go new file mode 100644 index 0000000..8683115 --- /dev/null +++ b/internal/store/image/memory_task_repository.go @@ -0,0 +1,62 @@ +package image + +import ( + "sync" + + appimage "github.com/JSYoo5B/SandStack/internal/app/image" +) + +type MemoryTaskRepository struct { + mu sync.RWMutex + ids []string + tasks map[string]appimage.Task +} + +func NewMemoryTaskRepository() *MemoryTaskRepository { + return &MemoryTaskRepository{ + ids: []string{}, + tasks: map[string]appimage.Task{}, + } +} + +func (r *MemoryTaskRepository) Create(task appimage.Task) appimage.Task { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, task.ID) + r.tasks[task.ID] = task + + return task +} + +func (r *MemoryTaskRepository) List() []appimage.Task { + r.mu.RLock() + defer r.mu.RUnlock() + + tasks := make([]appimage.Task, 0, len(r.ids)) + for _, id := range r.ids { + tasks = append(tasks, r.tasks[id]) + } + + return tasks +} + +func (r *MemoryTaskRepository) Get(id string) (appimage.Task, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + task, ok := r.tasks[id] + if !ok { + return appimage.Task{}, appimage.ErrTaskNotFound + } + + return task, nil +} + +func (r *MemoryTaskRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.tasks = map[string]appimage.Task{} +}