diff --git a/internal/api/admin/request.go b/internal/api/admin/request.go new file mode 100644 index 0000000..f11b4d0 --- /dev/null +++ b/internal/api/admin/request.go @@ -0,0 +1,13 @@ +package admin + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listRequests(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, requestListResponse{ + Requests: toRequestDocuments(h.requests.List()), + }) +} diff --git a/internal/api/admin/request_dto.go b/internal/api/admin/request_dto.go new file mode 100644 index 0000000..30cb741 --- /dev/null +++ b/internal/api/admin/request_dto.go @@ -0,0 +1,28 @@ +package admin + +import "github.com/JSYoo5B/SandStack/internal/app/requestlog" + +type requestListResponse struct { + Requests []requestDocument `json:"requests"` +} + +type requestDocument struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` +} + +func toRequestDocuments(records []requestlog.Record) []requestDocument { + documents := make([]requestDocument, 0, len(records)) + for _, record := range records { + documents = append(documents, requestDocument{ + ID: record.ID, + Method: record.Method, + Path: record.Path, + Status: record.Status, + }) + } + + return documents +} diff --git a/internal/api/admin/reset.go b/internal/api/admin/reset.go new file mode 100644 index 0000000..4217ff1 --- /dev/null +++ b/internal/api/admin/reset.go @@ -0,0 +1,8 @@ +package admin + +import "net/http" + +func (h Handler) resetState(w http.ResponseWriter, _ *http.Request) { + h.reset() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/admin/router.go b/internal/api/admin/router.go index 1673789..9a8ea54 100644 --- a/internal/api/admin/router.go +++ b/internal/api/admin/router.go @@ -3,13 +3,36 @@ package admin import ( "net/http" + "github.com/JSYoo5B/SandStack/internal/app/requestlog" "github.com/go-chi/chi/v5" ) func NewRouter() http.Handler { + return NewRouterWithReset(func() {}) +} + +func NewRouterWithReset(reset func()) http.Handler { + return NewRouterWithState(reset, requestlog.NewService()) +} + +func NewRouterWithState( + reset func(), + requests *requestlog.Service, +) http.Handler { + handler := Handler{ + reset: reset, + requests: requests, + } router := chi.NewRouter() router.Get("/health", status) router.Get("/ready", status) + router.Post("/reset", handler.resetState) + router.Get("/requests", handler.listRequests) return router } + +type Handler struct { + reset func() + requests *requestlog.Service +} diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index 4101a71..d2171e7 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler { return NewHandler(cfg).Router() } +func NewRouterWithService( + cfg config.Config, + service *appcompute.Service, +) http.Handler { + return NewHandlerWithService(cfg, service).Router() +} + func NewHandler(cfg config.Config) Handler { + return NewHandlerWithService(cfg, appcompute.NewService()) +} + +func NewHandlerWithService( + cfg config.Config, + service *appcompute.Service, +) Handler { return Handler{ config: cfg, - service: appcompute.NewService(), + service: service, } } diff --git a/internal/api/image/router.go b/internal/api/image/router.go index e944a44..83c3220 100644 --- a/internal/api/image/router.go +++ b/internal/api/image/router.go @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler { return NewHandler(cfg).Router() } +func NewRouterWithService( + cfg config.Config, + service *appimage.Service, +) http.Handler { + return NewHandlerWithService(cfg, service).Router() +} + func NewHandler(cfg config.Config) Handler { + return NewHandlerWithService(cfg, appimage.NewService()) +} + +func NewHandlerWithService( + cfg config.Config, + service *appimage.Service, +) Handler { return Handler{ config: cfg, - service: appimage.NewService(), + service: service, } } diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..9942203 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,50 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/JSYoo5B/SandStack/internal/app/requestlog" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +func requestID(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Openstack-Request-Id", "req-"+idgen.RandomHex(16)) + next.ServeHTTP(w, r) + }) +} + +func recordRequests(requests *requestlog.Service) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := &statusRecorder{ + ResponseWriter: w, + status: http.StatusOK, + } + + next.ServeHTTP(recorder, r) + + if strings.HasPrefix(r.URL.Path, "/_sandstack") { + return + } + + requests.Add(requestlog.Record{ + ID: w.Header().Get("X-Openstack-Request-Id"), + Method: r.Method, + Path: r.URL.Path, + Status: recorder.status, + }) + }) + } +} + +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 0bd97a4..92ffdf7 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler { return NewHandler(cfg).Router() } +func NewRouterWithService( + cfg config.Config, + service *appnetwork.Service, +) http.Handler { + return NewHandlerWithService(cfg, service).Router() +} + func NewHandler(cfg config.Config) Handler { + return NewHandlerWithService(cfg, appnetwork.NewService()) +} + +func NewHandlerWithService( + cfg config.Config, + service *appnetwork.Service, +) Handler { return Handler{ config: cfg, - service: appnetwork.NewService(), + service: service, } } diff --git a/internal/api/router.go b/internal/api/router.go index 6f2c851..2426962 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10,34 +10,43 @@ import ( "github.com/JSYoo5B/SandStack/internal/api/network" "github.com/JSYoo5B/SandStack/internal/api/placement" "github.com/JSYoo5B/SandStack/internal/api/volume" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + appimage "github.com/JSYoo5B/SandStack/internal/app/image" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/JSYoo5B/SandStack/internal/app/requestlog" + appvolume "github.com/JSYoo5B/SandStack/internal/app/volume" "github.com/JSYoo5B/SandStack/internal/platform/config" - "github.com/JSYoo5B/SandStack/internal/platform/idgen" "github.com/go-chi/chi/v5" ) func NewRouter(cfg config.Config) http.Handler { router := chi.NewRouter() router.Use(requestID) + requests := requestlog.NewService() + router.Use(recordRequests(requests)) identityHandler := identity.NewHandler(cfg) - - router.Mount("/_sandstack", admin.NewRouter()) + computeService := appcompute.NewService() + imageService := appimage.NewService() + networkService := appnetwork.NewService() + volumeService := appvolume.NewService() + + router.Mount("/_sandstack", admin.NewRouterWithState(func() { + computeService.Reset() + imageService.Reset() + networkService.Reset() + volumeService.Reset() + requests.Reset() + }, requests)) router.Get("/identity", identityHandler.Discovery()) router.Get("/identity/", identityHandler.Discovery()) router.Mount("/identity/v3", identityHandler.Router()) - router.Mount("/compute/v2.1", compute.NewRouter(cfg)) - router.Mount("/image/v2", image.NewRouter(cfg)) - router.Mount("/network/v2.0", network.NewRouter(cfg)) + router.Mount("/compute/v2.1", compute.NewRouterWithService(cfg, computeService)) + router.Mount("/image/v2", image.NewRouterWithService(cfg, imageService)) + router.Mount("/network/v2.0", network.NewRouterWithService(cfg, networkService)) router.Mount("/placement", placement.NewRouter(cfg)) - router.Mount("/volume/v3", volume.NewRouter(cfg)) + router.Mount("/volume/v3", volume.NewRouterWithService(cfg, volumeService)) return router } - -func requestID(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Openstack-Request-Id", "req-"+idgen.RandomHex(16)) - next.ServeHTTP(w, r) - }) -} diff --git a/internal/api/router_test.go b/internal/api/router_test.go index 2500fdc..9e262f8 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -1,6 +1,7 @@ package api_test import ( + "encoding/json" "net/http" "net/http/httptest" "testing" @@ -9,6 +10,7 @@ import ( "github.com/JSYoo5B/SandStack/internal/platform/config" "github.com/JSYoo5B/SandStack/internal/testhelper" "github.com/gophercloud/gophercloud/v2/openstack" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "github.com/stretchr/testify/suite" ) @@ -49,3 +51,70 @@ func (s *RouterSuite) TestMountedIdentityPasswordAuth() { s.Assert().NotEmpty(provider.TokenID) } + +func (s *RouterSuite) TestMountedSandstackResetClearsState() { + created, err := images.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/image/v2"), + images.CreateOpts{ + Name: "ubuntu", + ContainerFormat: "bare", + DiskFormat: "qcow2", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + response, err := http.Post( + s.server.URL+"/_sandstack/reset", + "application/json", + nil, + ) + s.Require().NoError(err) + defer response.Body.Close() + + pages, err := images.List( + testhelper.ServiceClient(s.server.URL+"/image/v2"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := images.ExtractImages(pages) + s.Require().NoError(err) + + s.Assert().Equal(http.StatusNoContent, response.StatusCode) + s.Assert().Empty(list) +} + +func (s *RouterSuite) TestMountedSandstackRequestsRecordsOpenStackAPI() { + response, err := http.Get(s.server.URL + "/image/v2/images") + s.Require().NoError(err) + defer response.Body.Close() + + requestsResponse, err := http.Get(s.server.URL + "/_sandstack/requests") + s.Require().NoError(err) + defer requestsResponse.Body.Close() + + var body requestListResponse + err = json.NewDecoder(requestsResponse.Body).Decode(&body) + s.Require().NoError(err) + + s.Assert().Equal(http.StatusOK, response.StatusCode) + s.Assert().Equal(http.StatusOK, requestsResponse.StatusCode) + s.Require().Len(body.Requests, 1) + s.Assert().NotEmpty(body.Requests[0].ID) + s.Assert().Equal(http.MethodGet, body.Requests[0].Method) + s.Assert().Equal("/image/v2/images", body.Requests[0].Path) + s.Assert().Equal(http.StatusOK, body.Requests[0].Status) +} + +type requestListResponse struct { + Requests []requestDocument `json:"requests"` +} + +type requestDocument struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` +} diff --git a/internal/api/volume/router.go b/internal/api/volume/router.go index 5295c65..8e37ed5 100644 --- a/internal/api/volume/router.go +++ b/internal/api/volume/router.go @@ -17,10 +17,24 @@ func NewRouter(cfg config.Config) http.Handler { return NewHandler(cfg).Router() } +func NewRouterWithService( + cfg config.Config, + service *appvolume.Service, +) http.Handler { + return NewHandlerWithService(cfg, service).Router() +} + func NewHandler(cfg config.Config) Handler { + return NewHandlerWithService(cfg, appvolume.NewService()) +} + +func NewHandlerWithService( + cfg config.Config, + service *appvolume.Service, +) Handler { return Handler{ config: cfg, - service: appvolume.NewService(), + service: service, } } diff --git a/internal/app/compute/flavor.go b/internal/app/compute/flavor.go new file mode 100644 index 0000000..e024ab0 --- /dev/null +++ b/internal/app/compute/flavor.go @@ -0,0 +1,22 @@ +package compute + +import "errors" + +var ErrFlavorNotFound = errors.New("flavor not found") + +func (s *Service) ListFlavors() []Flavor { + flavors := make([]Flavor, 0, len(s.flavors)) + flavors = append(flavors, s.flavors...) + + return flavors +} + +func (s *Service) GetFlavor(id string) (Flavor, error) { + for _, flavor := range s.flavors { + if flavor.ID == id { + return flavor, nil + } + } + + return Flavor{}, ErrFlavorNotFound +} diff --git a/internal/app/compute/memory_server_repository.go b/internal/app/compute/memory_server_repository.go new file mode 100644 index 0000000..96789bc --- /dev/null +++ b/internal/app/compute/memory_server_repository.go @@ -0,0 +1,90 @@ +package compute + +import "sync" + +type MemoryServerRepository struct { + mu sync.RWMutex + ids []string + servers map[string]Server +} + +func NewMemoryServerRepository() *MemoryServerRepository { + return &MemoryServerRepository{ + ids: []string{}, + servers: map[string]Server{}, + } +} + +func (r *MemoryServerRepository) Create(server Server) Server { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, server.ID) + r.servers[server.ID] = server + + return server +} + +func (r *MemoryServerRepository) List() []Server { + r.mu.RLock() + defer r.mu.RUnlock() + + servers := make([]Server, 0, len(r.ids)) + for _, id := range r.ids { + servers = append(servers, r.servers[id]) + } + + return servers +} + +func (r *MemoryServerRepository) Get(id string) (Server, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + server, ok := r.servers[id] + if !ok { + return Server{}, ErrServerNotFound + } + + return server, nil +} + +func (r *MemoryServerRepository) Update(server Server) (Server, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.servers[server.ID]; !ok { + return Server{}, ErrServerNotFound + } + + r.servers[server.ID] = server + + return server, nil +} + +func (r *MemoryServerRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.servers[id]; !ok { + return ErrServerNotFound + } + + delete(r.servers, 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 *MemoryServerRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.servers = map[string]Server{} +} diff --git a/internal/app/compute/repository.go b/internal/app/compute/repository.go new file mode 100644 index 0000000..574c4c4 --- /dev/null +++ b/internal/app/compute/repository.go @@ -0,0 +1,10 @@ +package compute + +type ServerRepository interface { + Create(server Server) Server + List() []Server + Get(id string) (Server, error) + Update(server Server) (Server, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/compute/server.go b/internal/app/compute/server.go new file mode 100644 index 0000000..7129180 --- /dev/null +++ b/internal/app/compute/server.go @@ -0,0 +1,65 @@ +package compute + +import ( + "errors" + "time" +) + +var ErrServerNotFound = errors.New("server not found") + +const serverTimestampFormat = time.RFC3339 + +func (s *Service) ListServers() []Server { + servers := s.serverRepository.List() + for index, server := range servers { + servers[index] = s.activateServer(server) + } + + return servers +} + +func (s *Service) CreateServer(input CreateServer) Server { + now := s.clock.Now().UTC().Format(serverTimestampFormat) + server := Server{ + ID: "srv-" + s.idGen.Hex(16), + Name: input.Name, + ImageID: input.ImageID, + FlavorID: input.FlavorID, + TenantID: "demo", + UserID: "admin", + Status: "BUILD", + Progress: 0, + CreatedAt: now, + UpdatedAt: now, + Metadata: input.Metadata, + } + + return s.serverRepository.Create(server) +} + +func (s *Service) GetServer(id string) (Server, error) { + server, err := s.serverRepository.Get(id) + if err != nil { + return Server{}, err + } + + return s.activateServer(server), nil +} + +func (s *Service) DeleteServer(id string) error { + return s.serverRepository.Delete(id) +} + +func (s *Service) activateServer(server Server) Server { + if server.Status != "BUILD" { + return server + } + + server.Status = "ACTIVE" + server.Progress = 100 + server.UpdatedAt = s.clock.Now().UTC().Format(serverTimestampFormat) + + _, _ = s.serverRepository.Update(server) + + return server +} diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go new file mode 100644 index 0000000..7ce4ec0 --- /dev/null +++ b/internal/app/compute/server_test.go @@ -0,0 +1,85 @@ +package compute_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + "github.com/stretchr/testify/suite" +) + +type ServerSuite struct { + suite.Suite +} + +func TestServerSuite(t *testing.T) { + suite.Run(t, new(ServerSuite)) +} + +func (s *ServerSuite) TestCreateServerUsesInjectedClock() { + now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) + service := compute.NewServiceWithClock(clock.Fixed(now)) + + server := service.CreateServer(compute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + + s.Assert().Equal("2026-06-23T08:30:00Z", server.CreatedAt) + s.Assert().Equal("2026-06-23T08:30:00Z", server.UpdatedAt) +} + +func (s *ServerSuite) TestCreateServerUsesInjectedIDGenerator() { + service := compute.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("server-id"), + ) + + server := service.CreateServer(compute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + + s.Assert().Equal("srv-server-id", server.ID) +} + +func (s *ServerSuite) TestGetServerActivatesCreatedServer() { + now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) + service := compute.NewServiceWithRuntime( + clock.Fixed(now), + idgen.Fixed("server-id"), + ) + created := service.CreateServer(compute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + + found, err := service.GetServer(created.ID) + s.Require().NoError(err) + + s.Assert().Equal("BUILD", created.Status) + s.Assert().Equal("ACTIVE", found.Status) + s.Assert().Equal(100, found.Progress) + s.Assert().Equal("2026-06-23T08:30:00Z", found.UpdatedAt) +} + +func (s *ServerSuite) TestResetClearsServers() { + service := compute.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("server-id"), + ) + service.CreateServer(compute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + + service.Reset() + + s.Assert().Empty(service.ListServers()) +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index 8438cdb..bcf8463 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -1,24 +1,41 @@ package compute import ( - "errors" - "sync" - "time" - + "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) -var ErrFlavorNotFound = errors.New("flavor not found") -var ErrServerNotFound = errors.New("server not found") - type Service struct { - flavors []Flavor - mu sync.RWMutex - ids []string - servers map[string]Server + flavors []Flavor + serverRepository ServerRepository + clock clock.Clock + idGen idgen.Generator } func NewService() *Service { + return NewServiceWithRuntime(clock.Wall(), idgen.Random()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { + return NewServiceWithRuntime(clock, idgen.Random()) +} + +func NewServiceWithRuntime( + clock clock.Clock, + idGen idgen.Generator, +) *Service { + return NewServiceWithRepository( + NewMemoryServerRepository(), + clock, + idGen, + ) +} + +func NewServiceWithRepository( + serverRepository ServerRepository, + clock clock.Clock, + idGen idgen.Generator, +) *Service { return &Service{ flavors: []Flavor{ { @@ -35,92 +52,12 @@ func NewService() *Service { ExtraSpecs: map[string]string{}, }, }, - ids: []string{}, - servers: map[string]Server{}, - } -} - -func (s *Service) ListFlavors() []Flavor { - flavors := make([]Flavor, 0, len(s.flavors)) - flavors = append(flavors, s.flavors...) - - return flavors -} - -func (s *Service) GetFlavor(id string) (Flavor, error) { - for _, flavor := range s.flavors { - if flavor.ID == id { - return flavor, nil - } - } - - return Flavor{}, ErrFlavorNotFound -} - -func (s *Service) ListServers() []Server { - s.mu.RLock() - defer s.mu.RUnlock() - - servers := make([]Server, 0, len(s.ids)) - for _, id := range s.ids { - servers = append(servers, s.servers[id]) - } - - return servers -} - -func (s *Service) CreateServer(input CreateServer) Server { - now := time.Now().UTC().Format(time.RFC3339) - server := Server{ - ID: "srv-" + idgen.RandomHex(16), - Name: input.Name, - ImageID: input.ImageID, - FlavorID: input.FlavorID, - TenantID: "demo", - UserID: "admin", - Status: "BUILD", - Progress: 0, - CreatedAt: now, - UpdatedAt: now, - Metadata: input.Metadata, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, server.ID) - s.servers[server.ID] = server - - return server -} - -func (s *Service) GetServer(id string) (Server, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - server, ok := s.servers[id] - if !ok { - return Server{}, ErrServerNotFound + serverRepository: serverRepository, + clock: clock, + idGen: idGen, } - - return server, nil } -func (s *Service) DeleteServer(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.servers[id]; !ok { - return ErrServerNotFound - } - - delete(s.servers, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil +func (s *Service) Reset() { + s.serverRepository.Reset() } diff --git a/internal/app/image/memory_repository.go b/internal/app/image/memory_repository.go new file mode 100644 index 0000000..c4fb3eb --- /dev/null +++ b/internal/app/image/memory_repository.go @@ -0,0 +1,77 @@ +package image + +import "sync" + +type MemoryRepository struct { + mu sync.RWMutex + ids []string + images map[string]Image +} + +func NewMemoryRepository() *MemoryRepository { + return &MemoryRepository{ + ids: []string{}, + images: map[string]Image{}, + } +} + +func (r *MemoryRepository) Create(image Image) Image { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, image.ID) + r.images[image.ID] = image + + return image +} + +func (r *MemoryRepository) List() []Image { + r.mu.RLock() + defer r.mu.RUnlock() + + images := make([]Image, 0, len(r.ids)) + for _, id := range r.ids { + images = append(images, r.images[id]) + } + + return images +} + +func (r *MemoryRepository) Get(id string) (Image, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + image, ok := r.images[id] + if !ok { + return Image{}, ErrImageNotFound + } + + return image, nil +} + +func (r *MemoryRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.images[id]; !ok { + return ErrImageNotFound + } + + delete(r.images, 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 *MemoryRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.images = map[string]Image{} +} diff --git a/internal/app/image/repository.go b/internal/app/image/repository.go new file mode 100644 index 0000000..ac4bb3b --- /dev/null +++ b/internal/app/image/repository.go @@ -0,0 +1,9 @@ +package image + +type Repository interface { + Create(image Image) Image + List() []Image + Get(id string) (Image, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/image/service.go b/internal/app/image/service.go index 1b8c375..35f107a 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -2,31 +2,51 @@ package image import ( "errors" - "sync" "time" + "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) var ErrImageNotFound = errors.New("image not found") type Service struct { - mu sync.RWMutex - ids []string - images map[string]Image + repository Repository + clock clock.Clock + idGen idgen.Generator } func NewService() *Service { + return NewServiceWithRuntime(clock.Wall(), idgen.Random()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { + return NewServiceWithRuntime(clock, idgen.Random()) +} + +func NewServiceWithRuntime( + clock clock.Clock, + idGen idgen.Generator, +) *Service { + return NewServiceWithRepository(NewMemoryRepository(), clock, idGen) +} + +func NewServiceWithRepository( + repository Repository, + clock clock.Clock, + idGen idgen.Generator, +) *Service { return &Service{ - ids: []string{}, - images: map[string]Image{}, + repository: repository, + clock: clock, + idGen: idGen, } } func (s *Service) Create(input CreateImage) Image { - now := time.Now().UTC() + now := s.clock.Now().UTC() image := Image{ - ID: "img-" + idgen.RandomHex(16), + ID: "img-" + s.idGen.Hex(16), Name: input.Name, Status: "queued", ContainerFormat: input.ContainerFormat, @@ -40,54 +60,21 @@ func (s *Service) Create(input CreateImage) Image { UpdatedAt: now.Format(time.RFC3339), } - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, image.ID) - s.images[image.ID] = image - - return image + return s.repository.Create(image) } func (s *Service) List() []Image { - s.mu.RLock() - defer s.mu.RUnlock() - - images := make([]Image, 0, len(s.ids)) - for _, id := range s.ids { - images = append(images, s.images[id]) - } - - return images + return s.repository.List() } func (s *Service) Get(id string) (Image, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - image, ok := s.images[id] - if !ok { - return Image{}, ErrImageNotFound - } - - return image, nil + return s.repository.Get(id) } func (s *Service) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.images[id]; !ok { - return ErrImageNotFound - } - - delete(s.images, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } + return s.repository.Delete(id) +} - return nil +func (s *Service) Reset() { + s.repository.Reset() } diff --git a/internal/app/image/service_test.go b/internal/app/image/service_test.go new file mode 100644 index 0000000..0441b60 --- /dev/null +++ b/internal/app/image/service_test.go @@ -0,0 +1,64 @@ +package image_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} + +func (s *ServiceSuite) TestCreateImageUsesInjectedClock() { + now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) + service := image.NewServiceWithClock(clock.Fixed(now)) + + created := service.Create(image.CreateImage{ + Name: "ubuntu", + ContainerFormat: "bare", + DiskFormat: "qcow2", + }) + + s.Assert().Equal("2026-06-23T08:30:00Z", created.CreatedAt) + s.Assert().Equal("2026-06-23T08:30:00Z", created.UpdatedAt) +} + +func (s *ServiceSuite) TestCreateImageUsesInjectedIDGenerator() { + service := image.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("image-id"), + ) + + created := service.Create(image.CreateImage{ + Name: "ubuntu", + ContainerFormat: "bare", + DiskFormat: "qcow2", + }) + + s.Assert().Equal("img-image-id", created.ID) +} + +func (s *ServiceSuite) TestResetClearsImages() { + service := image.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("image-id"), + ) + service.Create(image.CreateImage{ + Name: "ubuntu", + ContainerFormat: "bare", + DiskFormat: "qcow2", + }) + + service.Reset() + + s.Assert().Empty(service.List()) +} diff --git a/internal/app/network/memory_network_repository.go b/internal/app/network/memory_network_repository.go new file mode 100644 index 0000000..7f03c92 --- /dev/null +++ b/internal/app/network/memory_network_repository.go @@ -0,0 +1,90 @@ +package network + +import "sync" + +type MemoryNetworkRepository struct { + mu sync.RWMutex + ids []string + networks map[string]Network +} + +func NewMemoryNetworkRepository() *MemoryNetworkRepository { + return &MemoryNetworkRepository{ + ids: []string{}, + networks: map[string]Network{}, + } +} + +func (r *MemoryNetworkRepository) Create(network Network) Network { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, network.ID) + r.networks[network.ID] = network + + return network +} + +func (r *MemoryNetworkRepository) List() []Network { + r.mu.RLock() + defer r.mu.RUnlock() + + networks := make([]Network, 0, len(r.ids)) + for _, id := range r.ids { + networks = append(networks, r.networks[id]) + } + + return networks +} + +func (r *MemoryNetworkRepository) Get(id string) (Network, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + network, ok := r.networks[id] + if !ok { + return Network{}, ErrNetworkNotFound + } + + return network, nil +} + +func (r *MemoryNetworkRepository) Update(network Network) (Network, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.networks[network.ID]; !ok { + return Network{}, ErrNetworkNotFound + } + + r.networks[network.ID] = network + + return network, nil +} + +func (r *MemoryNetworkRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.networks[id]; !ok { + return ErrNetworkNotFound + } + + delete(r.networks, 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 *MemoryNetworkRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.networks = map[string]Network{} +} diff --git a/internal/app/network/memory_port_repository.go b/internal/app/network/memory_port_repository.go new file mode 100644 index 0000000..a89b346 --- /dev/null +++ b/internal/app/network/memory_port_repository.go @@ -0,0 +1,77 @@ +package network + +import "sync" + +type MemoryPortRepository struct { + mu sync.RWMutex + ids []string + ports map[string]Port +} + +func NewMemoryPortRepository() *MemoryPortRepository { + return &MemoryPortRepository{ + ids: []string{}, + ports: map[string]Port{}, + } +} + +func (r *MemoryPortRepository) Create(port Port) Port { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, port.ID) + r.ports[port.ID] = port + + return port +} + +func (r *MemoryPortRepository) List() []Port { + r.mu.RLock() + defer r.mu.RUnlock() + + ports := make([]Port, 0, len(r.ids)) + for _, id := range r.ids { + ports = append(ports, r.ports[id]) + } + + return ports +} + +func (r *MemoryPortRepository) Get(id string) (Port, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + port, ok := r.ports[id] + if !ok { + return Port{}, ErrPortNotFound + } + + return port, nil +} + +func (r *MemoryPortRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.ports[id]; !ok { + return ErrPortNotFound + } + + delete(r.ports, 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 *MemoryPortRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.ports = map[string]Port{} +} diff --git a/internal/app/network/memory_subnet_repository.go b/internal/app/network/memory_subnet_repository.go new file mode 100644 index 0000000..148fc6f --- /dev/null +++ b/internal/app/network/memory_subnet_repository.go @@ -0,0 +1,77 @@ +package network + +import "sync" + +type MemorySubnetRepository struct { + mu sync.RWMutex + ids []string + subnets map[string]Subnet +} + +func NewMemorySubnetRepository() *MemorySubnetRepository { + return &MemorySubnetRepository{ + ids: []string{}, + subnets: map[string]Subnet{}, + } +} + +func (r *MemorySubnetRepository) Create(subnet Subnet) Subnet { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, subnet.ID) + r.subnets[subnet.ID] = subnet + + return subnet +} + +func (r *MemorySubnetRepository) List() []Subnet { + r.mu.RLock() + defer r.mu.RUnlock() + + subnets := make([]Subnet, 0, len(r.ids)) + for _, id := range r.ids { + subnets = append(subnets, r.subnets[id]) + } + + return subnets +} + +func (r *MemorySubnetRepository) Get(id string) (Subnet, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + subnet, ok := r.subnets[id] + if !ok { + return Subnet{}, ErrSubnetNotFound + } + + return subnet, nil +} + +func (r *MemorySubnetRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.subnets[id]; !ok { + return ErrSubnetNotFound + } + + delete(r.subnets, 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 *MemorySubnetRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.subnets = map[string]Subnet{} +} diff --git a/internal/app/network/network.go b/internal/app/network/network.go new file mode 100644 index 0000000..19e2f24 --- /dev/null +++ b/internal/app/network/network.go @@ -0,0 +1,40 @@ +package network + +import ( + "errors" +) + +var ErrNetworkNotFound = errors.New("network not found") + +func (s *Service) Create(input CreateNetwork) Network { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + network := Network{ + ID: "net-" + s.idGen.Hex(16), + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "ACTIVE", + Subnets: []string{}, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + Shared: input.Shared, + } + + return s.networkRepository.Create(network) +} + +func (s *Service) List() []Network { + return s.networkRepository.List() +} + +func (s *Service) Get(id string) (Network, error) { + return s.networkRepository.Get(id) +} + +func (s *Service) Delete(id string) error { + return s.networkRepository.Delete(id) +} diff --git a/internal/app/network/port.go b/internal/app/network/port.go new file mode 100644 index 0000000..0cf5efb --- /dev/null +++ b/internal/app/network/port.go @@ -0,0 +1,44 @@ +package network + +import ( + "errors" +) + +var ErrPortNotFound = errors.New("port not found") + +func (s *Service) ListPorts() []Port { + return s.portRepository.List() +} + +func (s *Service) CreatePort(input CreatePort) Port { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + id := "port-" + s.idGen.Hex(16) + port := Port{ + ID: id, + NetworkID: input.NetworkID, + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "DOWN", + MACAddress: "fa:16:3e:" + s.idGen.Hex(6), + FixedIPs: input.FixedIPs, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + DeviceID: input.DeviceID, + DeviceOwner: input.DeviceOwner, + } + + return s.portRepository.Create(port) +} + +func (s *Service) GetPort(id string) (Port, error) { + return s.portRepository.Get(id) +} + +func (s *Service) DeletePort(id string) error { + return s.portRepository.Delete(id) +} diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go new file mode 100644 index 0000000..7d414e5 --- /dev/null +++ b/internal/app/network/repository.go @@ -0,0 +1,26 @@ +package network + +type NetworkRepository interface { + Create(network Network) Network + List() []Network + Get(id string) (Network, error) + Update(network Network) (Network, error) + Delete(id string) error + Reset() +} + +type SubnetRepository interface { + Create(subnet Subnet) Subnet + List() []Subnet + Get(id string) (Subnet, error) + Delete(id string) error + Reset() +} + +type PortRepository interface { + Create(port Port) Port + List() []Port + Get(id string) (Port, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index eabeedc..1093ccd 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -1,272 +1,51 @@ package network import ( - "errors" "sync" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) -var ErrNetworkNotFound = errors.New("network not found") -var ErrSubnetNotFound = errors.New("subnet not found") -var ErrPortNotFound = errors.New("port not found") - type Service struct { - mu sync.RWMutex - ids []string - networks map[string]Network - subnetIDs []string - subnets map[string]Subnet - portIDs []string - ports map[string]Port + mu sync.RWMutex + networkRepository NetworkRepository + subnetRepository SubnetRepository + portRepository PortRepository + idGen idgen.Generator } func NewService() *Service { - return &Service{ - ids: []string{}, - networks: map[string]Network{}, - subnetIDs: []string{}, - subnets: map[string]Subnet{}, - portIDs: []string{}, - ports: map[string]Port{}, - } + return NewServiceWithIDGenerator(idgen.Random()) } -func (s *Service) Create(input CreateNetwork) Network { - adminStateUp := true - if input.AdminStateUp != nil { - adminStateUp = *input.AdminStateUp - } - - network := Network{ - ID: "net-" + idgen.RandomHex(16), - Name: input.Name, - Description: input.Description, - AdminStateUp: adminStateUp, - Status: "ACTIVE", - Subnets: []string{}, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - Shared: input.Shared, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, network.ID) - s.networks[network.ID] = network - - return network +func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { + return NewServiceWithRepositories( + NewMemoryNetworkRepository(), + NewMemorySubnetRepository(), + NewMemoryPortRepository(), + idGen, + ) } -func (s *Service) List() []Network { - s.mu.RLock() - defer s.mu.RUnlock() - - networks := make([]Network, 0, len(s.ids)) - for _, id := range s.ids { - networks = append(networks, s.networks[id]) - } - - return networks -} - -func (s *Service) Get(id string) (Network, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - network, ok := s.networks[id] - if !ok { - return Network{}, ErrNetworkNotFound - } - - return network, nil -} - -func (s *Service) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.networks[id]; !ok { - return ErrNetworkNotFound - } - - delete(s.networks, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil -} - -func (s *Service) ListSubnets() []Subnet { - s.mu.RLock() - defer s.mu.RUnlock() - - subnets := make([]Subnet, 0, len(s.subnetIDs)) - for _, id := range s.subnetIDs { - subnets = append(subnets, s.subnets[id]) - } - - return subnets -} - -func (s *Service) CreateSubnet(input CreateSubnet) Subnet { - enableDHCP := true - if input.EnableDHCP != nil { - enableDHCP = *input.EnableDHCP - } - - subnet := Subnet{ - ID: "subnet-" + idgen.RandomHex(16), - NetworkID: input.NetworkID, - Name: input.Name, - Description: input.Description, - IPVersion: input.IPVersion, - CIDR: input.CIDR, - GatewayIP: input.GatewayIP, - DNSNameservers: input.DNSNameservers, - EnableDHCP: enableDHCP, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.subnetIDs = append(s.subnetIDs, subnet.ID) - s.subnets[subnet.ID] = subnet - - network, ok := s.networks[subnet.NetworkID] - if ok { - network.Subnets = append(network.Subnets, subnet.ID) - s.networks[subnet.NetworkID] = network - } - - return subnet -} - -func (s *Service) GetSubnet(id string) (Subnet, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - subnet, ok := s.subnets[id] - if !ok { - return Subnet{}, ErrSubnetNotFound - } - - return subnet, nil -} - -func (s *Service) DeleteSubnet(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - subnet, ok := s.subnets[id] - if !ok { - return ErrSubnetNotFound - } - - delete(s.subnets, id) - for index, currentID := range s.subnetIDs { - if currentID == id { - s.subnetIDs = append(s.subnetIDs[:index], s.subnetIDs[index+1:]...) - break - } - } - - network, ok := s.networks[subnet.NetworkID] - if ok { - network.Subnets = removeString(network.Subnets, id) - s.networks[subnet.NetworkID] = network - } - - return nil -} - -func removeString(values []string, target string) []string { - for index, value := range values { - if value == target { - return append(values[:index], values[index+1:]...) - } - } - - return values -} - -func (s *Service) ListPorts() []Port { - s.mu.RLock() - defer s.mu.RUnlock() - - ports := make([]Port, 0, len(s.portIDs)) - for _, id := range s.portIDs { - ports = append(ports, s.ports[id]) - } - - return ports -} - -func (s *Service) CreatePort(input CreatePort) Port { - adminStateUp := true - if input.AdminStateUp != nil { - adminStateUp = *input.AdminStateUp - } - - id := "port-" + idgen.RandomHex(16) - port := Port{ - ID: id, - NetworkID: input.NetworkID, - Name: input.Name, - Description: input.Description, - AdminStateUp: adminStateUp, - Status: "DOWN", - MACAddress: "fa:16:3e:" + idgen.RandomHex(6), - FixedIPs: input.FixedIPs, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - DeviceID: input.DeviceID, - DeviceOwner: input.DeviceOwner, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.portIDs = append(s.portIDs, port.ID) - s.ports[port.ID] = port - - return port -} - -func (s *Service) GetPort(id string) (Port, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - port, ok := s.ports[id] - if !ok { - return Port{}, ErrPortNotFound +func NewServiceWithRepositories( + networkRepository NetworkRepository, + subnetRepository SubnetRepository, + portRepository PortRepository, + idGen idgen.Generator, +) *Service { + return &Service{ + networkRepository: networkRepository, + subnetRepository: subnetRepository, + portRepository: portRepository, + idGen: idGen, } - - return port, nil } -func (s *Service) DeletePort(id string) error { +func (s *Service) Reset() { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.ports[id]; !ok { - return ErrPortNotFound - } - - delete(s.ports, id) - for index, currentID := range s.portIDs { - if currentID == id { - s.portIDs = append(s.portIDs[:index], s.portIDs[index+1:]...) - break - } - } - - return nil + s.networkRepository.Reset() + s.subnetRepository.Reset() + s.portRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go new file mode 100644 index 0000000..c140583 --- /dev/null +++ b/internal/app/network/service_test.go @@ -0,0 +1,61 @@ +package network_test + +import ( + "testing" + + "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} + +func (s *ServiceSuite) TestCreateNetworkUsesInjectedIDGenerator() { + service := network.NewServiceWithIDGenerator(idgen.Fixed("network-id")) + + created := service.Create(network.CreateNetwork{Name: "private"}) + + s.Assert().Equal("net-network-id", created.ID) +} + +func (s *ServiceSuite) TestCreateSubnetUsesInjectedIDGenerator() { + service := network.NewServiceWithIDGenerator(idgen.Fixed("subnet-id")) + + created := service.CreateSubnet(network.CreateSubnet{ + NetworkID: "net-1", + Name: "private-subnet", + }) + + s.Assert().Equal("subnet-subnet-id", created.ID) +} + +func (s *ServiceSuite) TestCreatePortUsesInjectedIDGenerator() { + service := network.NewServiceWithIDGenerator(idgen.Fixed("port-id")) + + created := service.CreatePort(network.CreatePort{ + NetworkID: "net-1", + Name: "private-port", + }) + + s.Assert().Equal("port-port-id", created.ID) + s.Assert().Equal("fa:16:3e:port-id", created.MACAddress) +} + +func (s *ServiceSuite) TestResetClearsNetworkResources() { + service := network.NewServiceWithIDGenerator(idgen.Fixed("network-id")) + created := service.Create(network.CreateNetwork{Name: "private"}) + service.CreateSubnet(network.CreateSubnet{NetworkID: created.ID}) + service.CreatePort(network.CreatePort{NetworkID: created.ID}) + + service.Reset() + + s.Assert().Empty(service.List()) + s.Assert().Empty(service.ListSubnets()) + s.Assert().Empty(service.ListPorts()) +} diff --git a/internal/app/network/subnet.go b/internal/app/network/subnet.go new file mode 100644 index 0000000..931cb5e --- /dev/null +++ b/internal/app/network/subnet.go @@ -0,0 +1,81 @@ +package network + +import ( + "errors" +) + +var ErrSubnetNotFound = errors.New("subnet not found") + +func (s *Service) ListSubnets() []Subnet { + return s.subnetRepository.List() +} + +func (s *Service) CreateSubnet(input CreateSubnet) Subnet { + enableDHCP := true + if input.EnableDHCP != nil { + enableDHCP = *input.EnableDHCP + } + + subnet := Subnet{ + ID: "subnet-" + s.idGen.Hex(16), + NetworkID: input.NetworkID, + Name: input.Name, + Description: input.Description, + IPVersion: input.IPVersion, + CIDR: input.CIDR, + GatewayIP: input.GatewayIP, + DNSNameservers: input.DNSNameservers, + EnableDHCP: enableDHCP, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.subnetRepository.Create(subnet) + + network, err := s.networkRepository.Get(subnet.NetworkID) + if err == nil { + network.Subnets = append(network.Subnets, subnet.ID) + _, _ = s.networkRepository.Update(network) + } + + return subnet +} + +func (s *Service) GetSubnet(id string) (Subnet, error) { + return s.subnetRepository.Get(id) +} + +func (s *Service) DeleteSubnet(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + subnet, err := s.subnetRepository.Get(id) + if err != nil { + return err + } + + if err := s.subnetRepository.Delete(id); err != nil { + return err + } + + network, err := s.networkRepository.Get(subnet.NetworkID) + if err == nil { + network.Subnets = removeString(network.Subnets, id) + _, _ = s.networkRepository.Update(network) + } + + return nil +} + +func removeString(values []string, target string) []string { + for index, value := range values { + if value == target { + return append(values[:index], values[index+1:]...) + } + } + + return values +} diff --git a/internal/app/requestlog/service.go b/internal/app/requestlog/service.go new file mode 100644 index 0000000..4098b6d --- /dev/null +++ b/internal/app/requestlog/service.go @@ -0,0 +1,36 @@ +package requestlog + +import "sync" + +type Service struct { + mu sync.RWMutex + records []Record +} + +func NewService() *Service { + return &Service{records: []Record{}} +} + +func (s *Service) Add(record Record) { + s.mu.Lock() + defer s.mu.Unlock() + + s.records = append(s.records, record) +} + +func (s *Service) List() []Record { + s.mu.RLock() + defer s.mu.RUnlock() + + records := make([]Record, 0, len(s.records)) + records = append(records, s.records...) + + return records +} + +func (s *Service) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.records = []Record{} +} diff --git a/internal/app/requestlog/service_test.go b/internal/app/requestlog/service_test.go new file mode 100644 index 0000000..651c010 --- /dev/null +++ b/internal/app/requestlog/service_test.go @@ -0,0 +1,45 @@ +package requestlog_test + +import ( + "net/http" + "testing" + + "github.com/JSYoo5B/SandStack/internal/app/requestlog" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} + +func (s *ServiceSuite) TestAddThenListRecords() { + service := requestlog.NewService() + + service.Add(requestlog.Record{ + ID: "req-1", + Method: http.MethodGet, + Path: "/image/v2/images", + Status: http.StatusOK, + }) + + records := service.List() + + s.Require().Len(records, 1) + s.Assert().Equal("req-1", records[0].ID) + s.Assert().Equal(http.MethodGet, records[0].Method) + s.Assert().Equal("/image/v2/images", records[0].Path) + s.Assert().Equal(http.StatusOK, records[0].Status) +} + +func (s *ServiceSuite) TestResetClearsRecords() { + service := requestlog.NewService() + service.Add(requestlog.Record{ID: "req-1"}) + + service.Reset() + + s.Assert().Empty(service.List()) +} diff --git a/internal/app/requestlog/types.go b/internal/app/requestlog/types.go new file mode 100644 index 0000000..c0d5b25 --- /dev/null +++ b/internal/app/requestlog/types.go @@ -0,0 +1,8 @@ +package requestlog + +type Record struct { + ID string + Method string + Path string + Status int +} diff --git a/internal/app/volume/memory_repository.go b/internal/app/volume/memory_repository.go new file mode 100644 index 0000000..feb1aa8 --- /dev/null +++ b/internal/app/volume/memory_repository.go @@ -0,0 +1,90 @@ +package volume + +import "sync" + +type MemoryRepository struct { + mu sync.RWMutex + ids []string + volumes map[string]Volume +} + +func NewMemoryRepository() *MemoryRepository { + return &MemoryRepository{ + ids: []string{}, + volumes: map[string]Volume{}, + } +} + +func (r *MemoryRepository) Create(volume Volume) Volume { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, volume.ID) + r.volumes[volume.ID] = volume + + return volume +} + +func (r *MemoryRepository) List() []Volume { + r.mu.RLock() + defer r.mu.RUnlock() + + volumes := make([]Volume, 0, len(r.ids)) + for _, id := range r.ids { + volumes = append(volumes, r.volumes[id]) + } + + return volumes +} + +func (r *MemoryRepository) Get(id string) (Volume, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + volume, ok := r.volumes[id] + if !ok { + return Volume{}, ErrVolumeNotFound + } + + return volume, nil +} + +func (r *MemoryRepository) Update(volume Volume) (Volume, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.volumes[volume.ID]; !ok { + return Volume{}, ErrVolumeNotFound + } + + r.volumes[volume.ID] = volume + + return volume, nil +} + +func (r *MemoryRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.volumes[id]; !ok { + return ErrVolumeNotFound + } + + delete(r.volumes, 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 *MemoryRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.volumes = map[string]Volume{} +} diff --git a/internal/app/volume/repository.go b/internal/app/volume/repository.go new file mode 100644 index 0000000..b099306 --- /dev/null +++ b/internal/app/volume/repository.go @@ -0,0 +1,10 @@ +package volume + +type Repository interface { + Create(volume Volume) Volume + List() []Volume + Get(id string) (Volume, error) + Update(volume Volume) (Volume, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 2ac24a2..3076014 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -1,29 +1,39 @@ package volume import ( - "errors" - "sync" - "time" - + "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) -const timestampFormat = "2006-01-02T15:04:05.999999" - -var ErrVolumeNotFound = errors.New("volume not found") -var ErrVolumeTypeNotFound = errors.New("volume type not found") - type Service struct { - mu sync.RWMutex - ids []string - volumes map[string]Volume + repository Repository volumeTypes []VolumeType + clock clock.Clock + idGen idgen.Generator } func NewService() *Service { + return NewServiceWithRuntime(clock.Wall(), idgen.Random()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { + return NewServiceWithRuntime(clock, idgen.Random()) +} + +func NewServiceWithRuntime( + clock clock.Clock, + idGen idgen.Generator, +) *Service { + return NewServiceWithRepository(NewMemoryRepository(), clock, idGen) +} + +func NewServiceWithRepository( + repository Repository, + clock clock.Clock, + idGen idgen.Generator, +) *Service { return &Service{ - ids: []string{}, - volumes: map[string]Volume{}, + repository: repository, volumeTypes: []VolumeType{ { ID: "default", @@ -33,89 +43,11 @@ func NewService() *Service { IsPublic: true, }, }, + clock: clock, + idGen: idGen, } } -func (s *Service) Create(input CreateVolume) Volume { - now := time.Now().UTC().Format(timestampFormat) - volume := Volume{ - ID: "vol-" + idgen.RandomHex(16), - Status: "creating", - Size: input.Size, - Name: input.Name, - Description: input.Description, - VolumeType: input.VolumeType, - Metadata: input.Metadata, - CreatedAt: now, - UpdatedAt: now, - Bootable: "false", - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, volume.ID) - s.volumes[volume.ID] = volume - - return volume -} - -func (s *Service) List() []Volume { - s.mu.RLock() - defer s.mu.RUnlock() - - volumes := make([]Volume, 0, len(s.ids)) - for _, id := range s.ids { - volumes = append(volumes, s.volumes[id]) - } - - return volumes -} - -func (s *Service) Get(id string) (Volume, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - volume, ok := s.volumes[id] - if !ok { - return Volume{}, ErrVolumeNotFound - } - - return volume, nil -} - -func (s *Service) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.volumes[id]; !ok { - return ErrVolumeNotFound - } - - delete(s.volumes, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil -} - -func (s *Service) ListVolumeTypes() []VolumeType { - volumeTypes := make([]VolumeType, 0, len(s.volumeTypes)) - volumeTypes = append(volumeTypes, s.volumeTypes...) - - return volumeTypes -} - -func (s *Service) GetVolumeType(id string) (VolumeType, error) { - for _, volumeType := range s.volumeTypes { - if volumeType.ID == id { - return volumeType, nil - } - } - - return VolumeType{}, ErrVolumeTypeNotFound +func (s *Service) Reset() { + s.repository.Reset() } diff --git a/internal/app/volume/type.go b/internal/app/volume/type.go new file mode 100644 index 0000000..9c711bf --- /dev/null +++ b/internal/app/volume/type.go @@ -0,0 +1,22 @@ +package volume + +import "errors" + +var ErrVolumeTypeNotFound = errors.New("volume type not found") + +func (s *Service) ListVolumeTypes() []VolumeType { + volumeTypes := make([]VolumeType, 0, len(s.volumeTypes)) + volumeTypes = append(volumeTypes, s.volumeTypes...) + + return volumeTypes +} + +func (s *Service) GetVolumeType(id string) (VolumeType, error) { + for _, volumeType := range s.volumeTypes { + if volumeType.ID == id { + return volumeType, nil + } + } + + return VolumeType{}, ErrVolumeTypeNotFound +} diff --git a/internal/app/volume/volume.go b/internal/app/volume/volume.go new file mode 100644 index 0000000..535586e --- /dev/null +++ b/internal/app/volume/volume.go @@ -0,0 +1,62 @@ +package volume + +import ( + "errors" +) + +const timestampFormat = "2006-01-02T15:04:05.999999" + +var ErrVolumeNotFound = errors.New("volume not found") + +func (s *Service) Create(input CreateVolume) Volume { + now := s.clock.Now().UTC().Format(timestampFormat) + volume := Volume{ + ID: "vol-" + s.idGen.Hex(16), + Status: "creating", + Size: input.Size, + Name: input.Name, + Description: input.Description, + VolumeType: input.VolumeType, + Metadata: input.Metadata, + CreatedAt: now, + UpdatedAt: now, + Bootable: "false", + } + + return s.repository.Create(volume) +} + +func (s *Service) List() []Volume { + volumes := s.repository.List() + for index, volume := range volumes { + volumes[index] = s.makeVolumeAvailable(volume) + } + + return volumes +} + +func (s *Service) Get(id string) (Volume, error) { + volume, err := s.repository.Get(id) + if err != nil { + return Volume{}, err + } + + return s.makeVolumeAvailable(volume), nil +} + +func (s *Service) Delete(id string) error { + return s.repository.Delete(id) +} + +func (s *Service) makeVolumeAvailable(volume Volume) Volume { + if volume.Status != "creating" { + return volume + } + + volume.Status = "available" + volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + + _, _ = s.repository.Update(volume) + + return volume +} diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go new file mode 100644 index 0000000..5b7e68e --- /dev/null +++ b/internal/app/volume/volume_test.go @@ -0,0 +1,80 @@ +package volume_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/volume" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + "github.com/stretchr/testify/suite" +) + +type VolumeSuite struct { + suite.Suite +} + +func TestVolumeSuite(t *testing.T) { + suite.Run(t, new(VolumeSuite)) +} + +func (s *VolumeSuite) TestCreateVolumeUsesInjectedClock() { + now := time.Date(2026, 6, 23, 8, 30, 0, 123456000, time.UTC) + service := volume.NewServiceWithClock(clock.Fixed(now)) + + created := service.Create(volume.CreateVolume{ + Size: 1, + Name: "database", + }) + + s.Assert().Equal("2026-06-23T08:30:00.123456", created.CreatedAt) + s.Assert().Equal("2026-06-23T08:30:00.123456", created.UpdatedAt) +} + +func (s *VolumeSuite) TestCreateVolumeUsesInjectedIDGenerator() { + service := volume.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("volume-id"), + ) + + created := service.Create(volume.CreateVolume{ + Size: 1, + Name: "database", + }) + + s.Assert().Equal("vol-volume-id", created.ID) +} + +func (s *VolumeSuite) TestGetVolumeMakesCreatedVolumeAvailable() { + now := time.Date(2026, 6, 23, 8, 30, 0, 123456000, time.UTC) + service := volume.NewServiceWithRuntime( + clock.Fixed(now), + idgen.Fixed("volume-id"), + ) + created := service.Create(volume.CreateVolume{ + Size: 1, + Name: "database", + }) + + found, err := service.Get(created.ID) + s.Require().NoError(err) + + s.Assert().Equal("creating", created.Status) + s.Assert().Equal("available", found.Status) + s.Assert().Equal("2026-06-23T08:30:00.123456", found.UpdatedAt) +} + +func (s *VolumeSuite) TestResetClearsVolumes() { + service := volume.NewServiceWithRuntime( + clock.Fixed(time.Time{}), + idgen.Fixed("volume-id"), + ) + service.Create(volume.CreateVolume{ + Size: 1, + Name: "database", + }) + + service.Reset() + + s.Assert().Empty(service.List()) +} diff --git a/internal/platform/clock/clock.go b/internal/platform/clock/clock.go new file mode 100644 index 0000000..bfe7338 --- /dev/null +++ b/internal/platform/clock/clock.go @@ -0,0 +1,29 @@ +package clock + +import "time" + +type Clock interface { + Now() time.Time +} + +type WallClock struct{} + +func Wall() Clock { + return WallClock{} +} + +func (WallClock) Now() time.Time { + return time.Now() +} + +type FixedClock struct { + Value time.Time +} + +func Fixed(value time.Time) Clock { + return FixedClock{Value: value} +} + +func (c FixedClock) Now() time.Time { + return c.Value +} diff --git a/internal/platform/idgen/idgen.go b/internal/platform/idgen/idgen.go index 6805671..c01369c 100644 --- a/internal/platform/idgen/idgen.go +++ b/internal/platform/idgen/idgen.go @@ -6,6 +6,32 @@ import ( "time" ) +type Generator interface { + Hex(size int) string +} + +type RandomGenerator struct{} + +func Random() Generator { + return RandomGenerator{} +} + +func (RandomGenerator) Hex(size int) string { + return RandomHex(size) +} + +type FixedGenerator struct { + Value string +} + +func Fixed(value string) Generator { + return FixedGenerator{Value: value} +} + +func (g FixedGenerator) Hex(_ int) string { + return g.Value +} + func RandomHex(size int) string { bytes := make([]byte, size) if _, err := rand.Read(bytes); err != nil {