From 771d43db2bcf39e221ed1c1474c9f93913ebc22f Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:33:04 +0900 Subject: [PATCH 01/23] refactor(network): split app service by resource --- internal/app/network/network.go | 79 ++++++++++ internal/app/network/port.go | 83 +++++++++++ internal/app/network/service.go | 250 +------------------------------- internal/app/network/subnet.go | 104 +++++++++++++ 4 files changed, 267 insertions(+), 249 deletions(-) create mode 100644 internal/app/network/network.go create mode 100644 internal/app/network/port.go create mode 100644 internal/app/network/subnet.go diff --git a/internal/app/network/network.go b/internal/app/network/network.go new file mode 100644 index 0000000..0a29de9 --- /dev/null +++ b/internal/app/network/network.go @@ -0,0 +1,79 @@ +package network + +import ( + "errors" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +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-" + idgen.RandomHex(16), + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "ACTIVE", + Subnets: []string{}, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + Shared: input.Shared, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, network.ID) + s.networks[network.ID] = network + + return network +} + +func (s *Service) List() []Network { + s.mu.RLock() + defer s.mu.RUnlock() + + networks := make([]Network, 0, len(s.ids)) + for _, id := range s.ids { + networks = append(networks, s.networks[id]) + } + + return networks +} + +func (s *Service) Get(id string) (Network, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + network, ok := s.networks[id] + if !ok { + return Network{}, ErrNetworkNotFound + } + + return network, nil +} + +func (s *Service) Delete(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.networks[id]; !ok { + return ErrNetworkNotFound + } + + delete(s.networks, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} diff --git a/internal/app/network/port.go b/internal/app/network/port.go new file mode 100644 index 0000000..15f0062 --- /dev/null +++ b/internal/app/network/port.go @@ -0,0 +1,83 @@ +package network + +import ( + "errors" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ErrPortNotFound = errors.New("port not found") + +func (s *Service) ListPorts() []Port { + s.mu.RLock() + defer s.mu.RUnlock() + + ports := make([]Port, 0, len(s.portIDs)) + for _, id := range s.portIDs { + ports = append(ports, s.ports[id]) + } + + return ports +} + +func (s *Service) CreatePort(input CreatePort) Port { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + id := "port-" + idgen.RandomHex(16) + port := Port{ + ID: id, + NetworkID: input.NetworkID, + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Status: "DOWN", + MACAddress: "fa:16:3e:" + idgen.RandomHex(6), + FixedIPs: input.FixedIPs, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + DeviceID: input.DeviceID, + DeviceOwner: input.DeviceOwner, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.portIDs = append(s.portIDs, port.ID) + s.ports[port.ID] = port + + return port +} + +func (s *Service) GetPort(id string) (Port, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + port, ok := s.ports[id] + if !ok { + return Port{}, ErrPortNotFound + } + + return port, nil +} + +func (s *Service) DeletePort(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.ports[id]; !ok { + return ErrPortNotFound + } + + delete(s.ports, id) + for index, currentID := range s.portIDs { + if currentID == id { + s.portIDs = append(s.portIDs[:index], s.portIDs[index+1:]...) + break + } + } + + return nil +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index eabeedc..3df20cd 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -1,15 +1,6 @@ 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") +import "sync" type Service struct { mu sync.RWMutex @@ -31,242 +22,3 @@ func NewService() *Service { ports: map[string]Port{}, } } - -func (s *Service) Create(input CreateNetwork) Network { - adminStateUp := true - if input.AdminStateUp != nil { - adminStateUp = *input.AdminStateUp - } - - network := Network{ - ID: "net-" + idgen.RandomHex(16), - Name: input.Name, - Description: input.Description, - AdminStateUp: adminStateUp, - Status: "ACTIVE", - Subnets: []string{}, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - Shared: input.Shared, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, network.ID) - s.networks[network.ID] = network - - return network -} - -func (s *Service) List() []Network { - s.mu.RLock() - defer s.mu.RUnlock() - - networks := make([]Network, 0, len(s.ids)) - for _, id := range s.ids { - networks = append(networks, s.networks[id]) - } - - return networks -} - -func (s *Service) Get(id string) (Network, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - network, ok := s.networks[id] - if !ok { - return Network{}, ErrNetworkNotFound - } - - return network, nil -} - -func (s *Service) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.networks[id]; !ok { - return ErrNetworkNotFound - } - - delete(s.networks, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil -} - -func (s *Service) ListSubnets() []Subnet { - s.mu.RLock() - defer s.mu.RUnlock() - - subnets := make([]Subnet, 0, len(s.subnetIDs)) - for _, id := range s.subnetIDs { - subnets = append(subnets, s.subnets[id]) - } - - return subnets -} - -func (s *Service) CreateSubnet(input CreateSubnet) Subnet { - enableDHCP := true - if input.EnableDHCP != nil { - enableDHCP = *input.EnableDHCP - } - - subnet := Subnet{ - ID: "subnet-" + idgen.RandomHex(16), - NetworkID: input.NetworkID, - Name: input.Name, - Description: input.Description, - IPVersion: input.IPVersion, - CIDR: input.CIDR, - GatewayIP: input.GatewayIP, - DNSNameservers: input.DNSNameservers, - EnableDHCP: enableDHCP, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.subnetIDs = append(s.subnetIDs, subnet.ID) - s.subnets[subnet.ID] = subnet - - network, ok := s.networks[subnet.NetworkID] - if ok { - network.Subnets = append(network.Subnets, subnet.ID) - s.networks[subnet.NetworkID] = network - } - - return subnet -} - -func (s *Service) GetSubnet(id string) (Subnet, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - subnet, ok := s.subnets[id] - if !ok { - return Subnet{}, ErrSubnetNotFound - } - - return subnet, nil -} - -func (s *Service) DeleteSubnet(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - subnet, ok := s.subnets[id] - if !ok { - return ErrSubnetNotFound - } - - delete(s.subnets, id) - for index, currentID := range s.subnetIDs { - if currentID == id { - s.subnetIDs = append(s.subnetIDs[:index], s.subnetIDs[index+1:]...) - break - } - } - - network, ok := s.networks[subnet.NetworkID] - if ok { - network.Subnets = removeString(network.Subnets, id) - s.networks[subnet.NetworkID] = network - } - - return nil -} - -func removeString(values []string, target string) []string { - for index, value := range values { - if value == target { - return append(values[:index], values[index+1:]...) - } - } - - return values -} - -func (s *Service) ListPorts() []Port { - s.mu.RLock() - defer s.mu.RUnlock() - - ports := make([]Port, 0, len(s.portIDs)) - for _, id := range s.portIDs { - ports = append(ports, s.ports[id]) - } - - return ports -} - -func (s *Service) CreatePort(input CreatePort) Port { - adminStateUp := true - if input.AdminStateUp != nil { - adminStateUp = *input.AdminStateUp - } - - id := "port-" + idgen.RandomHex(16) - port := Port{ - ID: id, - NetworkID: input.NetworkID, - Name: input.Name, - Description: input.Description, - AdminStateUp: adminStateUp, - Status: "DOWN", - MACAddress: "fa:16:3e:" + idgen.RandomHex(6), - FixedIPs: input.FixedIPs, - TenantID: input.ProjectID, - ProjectID: input.ProjectID, - DeviceID: input.DeviceID, - DeviceOwner: input.DeviceOwner, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.portIDs = append(s.portIDs, port.ID) - s.ports[port.ID] = port - - return port -} - -func (s *Service) GetPort(id string) (Port, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - port, ok := s.ports[id] - if !ok { - return Port{}, ErrPortNotFound - } - - return port, nil -} - -func (s *Service) DeletePort(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.ports[id]; !ok { - return ErrPortNotFound - } - - delete(s.ports, id) - for index, currentID := range s.portIDs { - if currentID == id { - s.portIDs = append(s.portIDs[:index], s.portIDs[index+1:]...) - break - } - } - - return nil -} diff --git a/internal/app/network/subnet.go b/internal/app/network/subnet.go new file mode 100644 index 0000000..d1404d5 --- /dev/null +++ b/internal/app/network/subnet.go @@ -0,0 +1,104 @@ +package network + +import ( + "errors" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ErrSubnetNotFound = errors.New("subnet not found") + +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 +} From d7dda14b60712a80cc9a775380c068b71765abda Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:34:00 +0900 Subject: [PATCH 02/23] refactor(compute): split app service by resource --- internal/app/compute/flavor.go | 22 ++++++++ internal/app/compute/server.go | 78 +++++++++++++++++++++++++++ internal/app/compute/service.go | 96 +-------------------------------- 3 files changed, 101 insertions(+), 95 deletions(-) create mode 100644 internal/app/compute/flavor.go create mode 100644 internal/app/compute/server.go 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/server.go b/internal/app/compute/server.go new file mode 100644 index 0000000..4e2f547 --- /dev/null +++ b/internal/app/compute/server.go @@ -0,0 +1,78 @@ +package compute + +import ( + "errors" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +var ErrServerNotFound = errors.New("server not found") + +func (s *Service) ListServers() []Server { + s.mu.RLock() + defer s.mu.RUnlock() + + servers := make([]Server, 0, len(s.ids)) + for _, id := range s.ids { + servers = append(servers, s.servers[id]) + } + + return servers +} + +func (s *Service) CreateServer(input CreateServer) Server { + now := time.Now().UTC().Format(time.RFC3339) + server := Server{ + ID: "srv-" + idgen.RandomHex(16), + Name: input.Name, + ImageID: input.ImageID, + FlavorID: input.FlavorID, + TenantID: "demo", + UserID: "admin", + Status: "BUILD", + Progress: 0, + CreatedAt: now, + UpdatedAt: now, + Metadata: input.Metadata, + } + + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = append(s.ids, server.ID) + s.servers[server.ID] = server + + return server +} + +func (s *Service) GetServer(id string) (Server, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + server, ok := s.servers[id] + if !ok { + return Server{}, ErrServerNotFound + } + + return server, nil +} + +func (s *Service) DeleteServer(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.servers[id]; !ok { + return ErrServerNotFound + } + + delete(s.servers, id) + for index, currentID := range s.ids { + if currentID == id { + s.ids = append(s.ids[:index], s.ids[index+1:]...) + break + } + } + + return nil +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index 8438cdb..269e0a0 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -1,15 +1,6 @@ package compute -import ( - "errors" - "sync" - "time" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" -) - -var ErrFlavorNotFound = errors.New("flavor not found") -var ErrServerNotFound = errors.New("server not found") +import "sync" type Service struct { flavors []Flavor @@ -39,88 +30,3 @@ func NewService() *Service { servers: map[string]Server{}, } } - -func (s *Service) ListFlavors() []Flavor { - flavors := make([]Flavor, 0, len(s.flavors)) - flavors = append(flavors, s.flavors...) - - return flavors -} - -func (s *Service) GetFlavor(id string) (Flavor, error) { - for _, flavor := range s.flavors { - if flavor.ID == id { - return flavor, nil - } - } - - return Flavor{}, ErrFlavorNotFound -} - -func (s *Service) ListServers() []Server { - s.mu.RLock() - defer s.mu.RUnlock() - - servers := make([]Server, 0, len(s.ids)) - for _, id := range s.ids { - servers = append(servers, s.servers[id]) - } - - return servers -} - -func (s *Service) CreateServer(input CreateServer) Server { - now := time.Now().UTC().Format(time.RFC3339) - server := Server{ - ID: "srv-" + idgen.RandomHex(16), - Name: input.Name, - ImageID: input.ImageID, - FlavorID: input.FlavorID, - TenantID: "demo", - UserID: "admin", - Status: "BUILD", - Progress: 0, - CreatedAt: now, - UpdatedAt: now, - Metadata: input.Metadata, - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, server.ID) - s.servers[server.ID] = server - - return server -} - -func (s *Service) GetServer(id string) (Server, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - server, ok := s.servers[id] - if !ok { - return Server{}, ErrServerNotFound - } - - return server, nil -} - -func (s *Service) DeleteServer(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.servers[id]; !ok { - return ErrServerNotFound - } - - delete(s.servers, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil -} From 2ff668f0cd40b0f40d1fcdc5d896cd39005e11d2 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:34:53 +0900 Subject: [PATCH 03/23] refactor(volume): split app service by resource --- internal/app/volume/service.go | 97 +--------------------------------- internal/app/volume/type.go | 22 ++++++++ internal/app/volume/volume.go | 79 +++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 96 deletions(-) create mode 100644 internal/app/volume/type.go create mode 100644 internal/app/volume/volume.go diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 2ac24a2..689ecb9 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -1,17 +1,6 @@ package volume -import ( - "errors" - "sync" - "time" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" -) - -const timestampFormat = "2006-01-02T15:04:05.999999" - -var ErrVolumeNotFound = errors.New("volume not found") -var ErrVolumeTypeNotFound = errors.New("volume type not found") +import "sync" type Service struct { mu sync.RWMutex @@ -35,87 +24,3 @@ func NewService() *Service { }, } } - -func (s *Service) Create(input CreateVolume) Volume { - now := time.Now().UTC().Format(timestampFormat) - volume := Volume{ - ID: "vol-" + idgen.RandomHex(16), - Status: "creating", - Size: input.Size, - Name: input.Name, - Description: input.Description, - VolumeType: input.VolumeType, - Metadata: input.Metadata, - CreatedAt: now, - UpdatedAt: now, - Bootable: "false", - } - - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, volume.ID) - s.volumes[volume.ID] = volume - - return volume -} - -func (s *Service) List() []Volume { - s.mu.RLock() - defer s.mu.RUnlock() - - volumes := make([]Volume, 0, len(s.ids)) - for _, id := range s.ids { - volumes = append(volumes, s.volumes[id]) - } - - return volumes -} - -func (s *Service) Get(id string) (Volume, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - volume, ok := s.volumes[id] - if !ok { - return Volume{}, ErrVolumeNotFound - } - - return volume, nil -} - -func (s *Service) Delete(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.volumes[id]; !ok { - return ErrVolumeNotFound - } - - delete(s.volumes, id) - for index, currentID := range s.ids { - if currentID == id { - s.ids = append(s.ids[:index], s.ids[index+1:]...) - break - } - } - - return nil -} - -func (s *Service) ListVolumeTypes() []VolumeType { - volumeTypes := make([]VolumeType, 0, len(s.volumeTypes)) - volumeTypes = append(volumeTypes, s.volumeTypes...) - - return volumeTypes -} - -func (s *Service) GetVolumeType(id string) (VolumeType, error) { - for _, volumeType := range s.volumeTypes { - if volumeType.ID == id { - return volumeType, nil - } - } - - return VolumeType{}, ErrVolumeTypeNotFound -} diff --git a/internal/app/volume/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..53c3e66 --- /dev/null +++ b/internal/app/volume/volume.go @@ -0,0 +1,79 @@ +package volume + +import ( + "errors" + "time" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) + +const timestampFormat = "2006-01-02T15:04:05.999999" + +var ErrVolumeNotFound = errors.New("volume not found") + +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 +} From 7d49463e3638efc6614c2890dcd0be41931c553d Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:36:14 +0900 Subject: [PATCH 04/23] feat(runtime): add injectable clock for compute --- internal/app/compute/server.go | 4 +++- internal/app/compute/server_test.go | 32 +++++++++++++++++++++++++++++ internal/app/compute/service.go | 12 ++++++++++- internal/platform/clock/clock.go | 29 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 internal/app/compute/server_test.go create mode 100644 internal/platform/clock/clock.go diff --git a/internal/app/compute/server.go b/internal/app/compute/server.go index 4e2f547..3ba2c4c 100644 --- a/internal/app/compute/server.go +++ b/internal/app/compute/server.go @@ -9,6 +9,8 @@ import ( var ErrServerNotFound = errors.New("server not found") +const serverTimestampFormat = time.RFC3339 + func (s *Service) ListServers() []Server { s.mu.RLock() defer s.mu.RUnlock() @@ -22,7 +24,7 @@ func (s *Service) ListServers() []Server { } func (s *Service) CreateServer(input CreateServer) Server { - now := time.Now().UTC().Format(time.RFC3339) + now := s.clock.Now().UTC().Format(serverTimestampFormat) server := Server{ ID: "srv-" + idgen.RandomHex(16), Name: input.Name, diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go new file mode 100644 index 0000000..8a6465f --- /dev/null +++ b/internal/app/compute/server_test.go @@ -0,0 +1,32 @@ +package compute_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "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) +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index 269e0a0..b6d71b8 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -1,15 +1,24 @@ package compute -import "sync" +import ( + "sync" + + "github.com/JSYoo5B/SandStack/internal/platform/clock" +) type Service struct { flavors []Flavor mu sync.RWMutex ids []string servers map[string]Server + clock clock.Clock } func NewService() *Service { + return NewServiceWithClock(clock.Wall()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { return &Service{ flavors: []Flavor{ { @@ -28,5 +37,6 @@ func NewService() *Service { }, ids: []string{}, servers: map[string]Server{}, + clock: clock, } } 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 +} From 9d17bd873d5351c20dcd5b65808b5bd6d6ee4bd2 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:37:01 +0900 Subject: [PATCH 05/23] feat(runtime): add injectable clock for images --- internal/app/image/service.go | 9 ++++++++- internal/app/image/service_test.go | 32 ++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 internal/app/image/service_test.go diff --git a/internal/app/image/service.go b/internal/app/image/service.go index 1b8c375..fbf7e03 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) @@ -14,17 +15,23 @@ type Service struct { mu sync.RWMutex ids []string images map[string]Image + clock clock.Clock } func NewService() *Service { + return NewServiceWithClock(clock.Wall()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { return &Service{ ids: []string{}, images: map[string]Image{}, + clock: clock, } } func (s *Service) Create(input CreateImage) Image { - now := time.Now().UTC() + now := s.clock.Now().UTC() image := Image{ ID: "img-" + idgen.RandomHex(16), Name: input.Name, diff --git a/internal/app/image/service_test.go b/internal/app/image/service_test.go new file mode 100644 index 0000000..0a34b6b --- /dev/null +++ b/internal/app/image/service_test.go @@ -0,0 +1,32 @@ +package image_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/image" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "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) +} From 457f3a7685e3015e2bd3bcb14b3c094f0b6752fd Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:37:44 +0900 Subject: [PATCH 06/23] feat(runtime): add injectable clock for volumes --- internal/app/volume/service.go | 12 +++++++++++- internal/app/volume/volume.go | 3 +-- internal/app/volume/volume_test.go | 31 ++++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) create mode 100644 internal/app/volume/volume_test.go diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 689ecb9..0191b5a 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -1,15 +1,24 @@ package volume -import "sync" +import ( + "sync" + + "github.com/JSYoo5B/SandStack/internal/platform/clock" +) type Service struct { mu sync.RWMutex ids []string volumes map[string]Volume volumeTypes []VolumeType + clock clock.Clock } func NewService() *Service { + return NewServiceWithClock(clock.Wall()) +} + +func NewServiceWithClock(clock clock.Clock) *Service { return &Service{ ids: []string{}, volumes: map[string]Volume{}, @@ -22,5 +31,6 @@ func NewService() *Service { IsPublic: true, }, }, + clock: clock, } } diff --git a/internal/app/volume/volume.go b/internal/app/volume/volume.go index 53c3e66..0fd889e 100644 --- a/internal/app/volume/volume.go +++ b/internal/app/volume/volume.go @@ -2,7 +2,6 @@ package volume import ( "errors" - "time" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) @@ -12,7 +11,7 @@ const timestampFormat = "2006-01-02T15:04:05.999999" var ErrVolumeNotFound = errors.New("volume not found") func (s *Service) Create(input CreateVolume) Volume { - now := time.Now().UTC().Format(timestampFormat) + now := s.clock.Now().UTC().Format(timestampFormat) volume := Volume{ ID: "vol-" + idgen.RandomHex(16), Status: "creating", diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go new file mode 100644 index 0000000..9416ef8 --- /dev/null +++ b/internal/app/volume/volume_test.go @@ -0,0 +1,31 @@ +package volume_test + +import ( + "testing" + "time" + + "github.com/JSYoo5B/SandStack/internal/app/volume" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "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) +} From 9caed462365cb2aeb88f839178dc9bc25a24d6e3 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:38:54 +0900 Subject: [PATCH 07/23] feat(runtime): add injectable id generator for compute --- internal/app/compute/server.go | 4 +--- internal/app/compute/server_test.go | 16 ++++++++++++++++ internal/app/compute/service.go | 12 +++++++++++- internal/platform/idgen/idgen.go | 26 ++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/internal/app/compute/server.go b/internal/app/compute/server.go index 3ba2c4c..950aab7 100644 --- a/internal/app/compute/server.go +++ b/internal/app/compute/server.go @@ -3,8 +3,6 @@ package compute import ( "errors" "time" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) var ErrServerNotFound = errors.New("server not found") @@ -26,7 +24,7 @@ func (s *Service) ListServers() []Server { func (s *Service) CreateServer(input CreateServer) Server { now := s.clock.Now().UTC().Format(serverTimestampFormat) server := Server{ - ID: "srv-" + idgen.RandomHex(16), + ID: "srv-" + s.idGen.Hex(16), Name: input.Name, ImageID: input.ImageID, FlavorID: input.FlavorID, diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go index 8a6465f..e6d9f48 100644 --- a/internal/app/compute/server_test.go +++ b/internal/app/compute/server_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -30,3 +31,18 @@ func (s *ServerSuite) TestCreateServerUsesInjectedClock() { 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) +} diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index b6d71b8..2c3b9f0 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) type Service struct { @@ -12,13 +13,21 @@ type Service struct { ids []string servers map[string]Server clock clock.Clock + idGen idgen.Generator } func NewService() *Service { - return NewServiceWithClock(clock.Wall()) + 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 &Service{ flavors: []Flavor{ { @@ -38,5 +47,6 @@ func NewServiceWithClock(clock clock.Clock) *Service { ids: []string{}, servers: map[string]Server{}, clock: clock, + idGen: idGen, } } 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 { From eccf0ad794f1b77371ba17d3b6a78aeca5394f7e Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:39:35 +0900 Subject: [PATCH 08/23] feat(runtime): add injectable id generator for images --- internal/app/image/service.go | 13 +++++++++++-- internal/app/image/service_test.go | 16 ++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/internal/app/image/service.go b/internal/app/image/service.go index fbf7e03..d1ab1c3 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -16,24 +16,33 @@ type Service struct { ids []string images map[string]Image clock clock.Clock + idGen idgen.Generator } func NewService() *Service { - return NewServiceWithClock(clock.Wall()) + 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 &Service{ ids: []string{}, images: map[string]Image{}, clock: clock, + idGen: idGen, } } func (s *Service) Create(input CreateImage) Image { 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, diff --git a/internal/app/image/service_test.go b/internal/app/image/service_test.go index 0a34b6b..d962693 100644 --- a/internal/app/image/service_test.go +++ b/internal/app/image/service_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -30,3 +31,18 @@ func (s *ServiceSuite) TestCreateImageUsesInjectedClock() { 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) +} From a43fb37490d2103634d8c2904722dc59b54ad571 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:40:12 +0900 Subject: [PATCH 09/23] feat(runtime): add injectable id generator for volumes --- internal/app/volume/service.go | 12 +++++++++++- internal/app/volume/volume.go | 4 +--- internal/app/volume/volume_test.go | 15 +++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/internal/app/volume/service.go b/internal/app/volume/service.go index 0191b5a..e5bad47 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -4,6 +4,7 @@ import ( "sync" "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) type Service struct { @@ -12,13 +13,21 @@ type Service struct { volumes map[string]Volume volumeTypes []VolumeType clock clock.Clock + idGen idgen.Generator } func NewService() *Service { - return NewServiceWithClock(clock.Wall()) + 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 &Service{ ids: []string{}, volumes: map[string]Volume{}, @@ -32,5 +41,6 @@ func NewServiceWithClock(clock clock.Clock) *Service { }, }, clock: clock, + idGen: idGen, } } diff --git a/internal/app/volume/volume.go b/internal/app/volume/volume.go index 0fd889e..8ef8102 100644 --- a/internal/app/volume/volume.go +++ b/internal/app/volume/volume.go @@ -2,8 +2,6 @@ package volume import ( "errors" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) const timestampFormat = "2006-01-02T15:04:05.999999" @@ -13,7 +11,7 @@ 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-" + idgen.RandomHex(16), + ID: "vol-" + s.idGen.Hex(16), Status: "creating", Size: input.Size, Name: input.Name, diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go index 9416ef8..bc1b675 100644 --- a/internal/app/volume/volume_test.go +++ b/internal/app/volume/volume_test.go @@ -6,6 +6,7 @@ import ( "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" ) @@ -29,3 +30,17 @@ func (s *VolumeSuite) TestCreateVolumeUsesInjectedClock() { 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) +} From 6a8421d9b75efd69e1bb5d6ad4e4f6c69649ffc1 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:41:06 +0900 Subject: [PATCH 10/23] feat(runtime): add injectable id generator for network --- internal/app/network/network.go | 4 +-- internal/app/network/port.go | 6 ++-- internal/app/network/service.go | 12 ++++++- internal/app/network/service_test.go | 48 ++++++++++++++++++++++++++++ internal/app/network/subnet.go | 4 +-- 5 files changed, 63 insertions(+), 11 deletions(-) create mode 100644 internal/app/network/service_test.go diff --git a/internal/app/network/network.go b/internal/app/network/network.go index 0a29de9..f75501c 100644 --- a/internal/app/network/network.go +++ b/internal/app/network/network.go @@ -2,8 +2,6 @@ package network import ( "errors" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) var ErrNetworkNotFound = errors.New("network not found") @@ -15,7 +13,7 @@ func (s *Service) Create(input CreateNetwork) Network { } network := Network{ - ID: "net-" + idgen.RandomHex(16), + ID: "net-" + s.idGen.Hex(16), Name: input.Name, Description: input.Description, AdminStateUp: adminStateUp, diff --git a/internal/app/network/port.go b/internal/app/network/port.go index 15f0062..08485c5 100644 --- a/internal/app/network/port.go +++ b/internal/app/network/port.go @@ -2,8 +2,6 @@ package network import ( "errors" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) var ErrPortNotFound = errors.New("port not found") @@ -26,7 +24,7 @@ func (s *Service) CreatePort(input CreatePort) Port { adminStateUp = *input.AdminStateUp } - id := "port-" + idgen.RandomHex(16) + id := "port-" + s.idGen.Hex(16) port := Port{ ID: id, NetworkID: input.NetworkID, @@ -34,7 +32,7 @@ func (s *Service) CreatePort(input CreatePort) Port { Description: input.Description, AdminStateUp: adminStateUp, Status: "DOWN", - MACAddress: "fa:16:3e:" + idgen.RandomHex(6), + MACAddress: "fa:16:3e:" + s.idGen.Hex(6), FixedIPs: input.FixedIPs, TenantID: input.ProjectID, ProjectID: input.ProjectID, diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 3df20cd..b0cdc68 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -1,6 +1,10 @@ package network -import "sync" +import ( + "sync" + + "github.com/JSYoo5B/SandStack/internal/platform/idgen" +) type Service struct { mu sync.RWMutex @@ -10,9 +14,14 @@ type Service struct { subnets map[string]Subnet portIDs []string ports map[string]Port + idGen idgen.Generator } func NewService() *Service { + return NewServiceWithIDGenerator(idgen.Random()) +} + +func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { return &Service{ ids: []string{}, networks: map[string]Network{}, @@ -20,5 +29,6 @@ func NewService() *Service { subnets: map[string]Subnet{}, portIDs: []string{}, ports: map[string]Port{}, + idGen: idGen, } } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go new file mode 100644 index 0000000..ad41905 --- /dev/null +++ b/internal/app/network/service_test.go @@ -0,0 +1,48 @@ +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) +} diff --git a/internal/app/network/subnet.go b/internal/app/network/subnet.go index d1404d5..750f545 100644 --- a/internal/app/network/subnet.go +++ b/internal/app/network/subnet.go @@ -2,8 +2,6 @@ package network import ( "errors" - - "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) var ErrSubnetNotFound = errors.New("subnet not found") @@ -27,7 +25,7 @@ func (s *Service) CreateSubnet(input CreateSubnet) Subnet { } subnet := Subnet{ - ID: "subnet-" + idgen.RandomHex(16), + ID: "subnet-" + s.idGen.Hex(16), NetworkID: input.NetworkID, Name: input.Name, Description: input.Description, From f83db37dc81f3bcf6f9d9d302aa796c1569dcfca Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:41:55 +0900 Subject: [PATCH 11/23] feat(compute): activate servers on read --- internal/app/compute/server.go | 31 +++++++++++++++++++++++++---- internal/app/compute/server_test.go | 21 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/internal/app/compute/server.go b/internal/app/compute/server.go index 950aab7..4b04f11 100644 --- a/internal/app/compute/server.go +++ b/internal/app/compute/server.go @@ -10,8 +10,10 @@ var ErrServerNotFound = errors.New("server not found") const serverTimestampFormat = time.RFC3339 func (s *Service) ListServers() []Server { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() + + s.activateServersLocked() servers := make([]Server, 0, len(s.ids)) for _, id := range s.ids { @@ -47,14 +49,17 @@ func (s *Service) CreateServer(input CreateServer) Server { } func (s *Service) GetServer(id string) (Server, error) { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() server, ok := s.servers[id] if !ok { return Server{}, ErrServerNotFound } + server = s.activateServerLocked(server) + s.servers[id] = server + return server, nil } @@ -76,3 +81,21 @@ func (s *Service) DeleteServer(id string) error { return nil } + +func (s *Service) activateServersLocked() { + for _, id := range s.ids { + s.servers[id] = s.activateServerLocked(s.servers[id]) + } +} + +func (s *Service) activateServerLocked(server Server) Server { + if server.Status != "BUILD" { + return server + } + + server.Status = "ACTIVE" + server.Progress = 100 + server.UpdatedAt = s.clock.Now().UTC().Format(serverTimestampFormat) + + return server +} diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go index e6d9f48..4435f96 100644 --- a/internal/app/compute/server_test.go +++ b/internal/app/compute/server_test.go @@ -46,3 +46,24 @@ func (s *ServerSuite) TestCreateServerUsesInjectedIDGenerator() { 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) +} From d44a9a99bcfce16315e0f8f6014363a63d1f7711 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:42:35 +0900 Subject: [PATCH 12/23] feat(volume): make volumes available on read --- internal/app/volume/volume.go | 30 ++++++++++++++++++++++++++---- internal/app/volume/volume_test.go | 19 +++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/internal/app/volume/volume.go b/internal/app/volume/volume.go index 8ef8102..3c4374b 100644 --- a/internal/app/volume/volume.go +++ b/internal/app/volume/volume.go @@ -33,8 +33,10 @@ func (s *Service) Create(input CreateVolume) Volume { } func (s *Service) List() []Volume { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() + + s.makeVolumesAvailableLocked() volumes := make([]Volume, 0, len(s.ids)) for _, id := range s.ids { @@ -45,14 +47,17 @@ func (s *Service) List() []Volume { } func (s *Service) Get(id string) (Volume, error) { - s.mu.RLock() - defer s.mu.RUnlock() + s.mu.Lock() + defer s.mu.Unlock() volume, ok := s.volumes[id] if !ok { return Volume{}, ErrVolumeNotFound } + volume = s.makeVolumeAvailableLocked(volume) + s.volumes[id] = volume + return volume, nil } @@ -74,3 +79,20 @@ func (s *Service) Delete(id string) error { return nil } + +func (s *Service) makeVolumesAvailableLocked() { + for _, id := range s.ids { + s.volumes[id] = s.makeVolumeAvailableLocked(s.volumes[id]) + } +} + +func (s *Service) makeVolumeAvailableLocked(volume Volume) Volume { + if volume.Status != "creating" { + return volume + } + + volume.Status = "available" + volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + + return volume +} diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go index bc1b675..ef97f3b 100644 --- a/internal/app/volume/volume_test.go +++ b/internal/app/volume/volume_test.go @@ -44,3 +44,22 @@ func (s *VolumeSuite) TestCreateVolumeUsesInjectedIDGenerator() { 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) +} From 6c80f495965882a27b4e53c3168f17285f522b19 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:43:38 +0900 Subject: [PATCH 13/23] feat(runtime): add app service reset support --- internal/app/compute/server_test.go | 16 ++++++++++++++++ internal/app/compute/service.go | 8 ++++++++ internal/app/image/service.go | 8 ++++++++ internal/app/image/service_test.go | 16 ++++++++++++++++ internal/app/network/service.go | 12 ++++++++++++ internal/app/network/service_test.go | 13 +++++++++++++ internal/app/volume/service.go | 8 ++++++++ internal/app/volume/volume_test.go | 15 +++++++++++++++ 8 files changed, 96 insertions(+) diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go index 4435f96..7ce4ec0 100644 --- a/internal/app/compute/server_test.go +++ b/internal/app/compute/server_test.go @@ -67,3 +67,19 @@ func (s *ServerSuite) TestGetServerActivatesCreatedServer() { 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 2c3b9f0..ac5e536 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -50,3 +50,11 @@ func NewServiceWithRuntime( idGen: idGen, } } + +func (s *Service) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = []string{} + s.servers = map[string]Server{} +} diff --git a/internal/app/image/service.go b/internal/app/image/service.go index d1ab1c3..82fcdba 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -107,3 +107,11 @@ func (s *Service) Delete(id string) error { return nil } + +func (s *Service) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = []string{} + s.images = map[string]Image{} +} diff --git a/internal/app/image/service_test.go b/internal/app/image/service_test.go index d962693..0441b60 100644 --- a/internal/app/image/service_test.go +++ b/internal/app/image/service_test.go @@ -46,3 +46,19 @@ func (s *ServiceSuite) TestCreateImageUsesInjectedIDGenerator() { 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/service.go b/internal/app/network/service.go index b0cdc68..38d9837 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -32,3 +32,15 @@ func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { idGen: idGen, } } + +func (s *Service) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = []string{} + s.networks = map[string]Network{} + s.subnetIDs = []string{} + s.subnets = map[string]Subnet{} + s.portIDs = []string{} + s.ports = map[string]Port{} +} diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index ad41905..c140583 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -46,3 +46,16 @@ func (s *ServiceSuite) TestCreatePortUsesInjectedIDGenerator() { 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/volume/service.go b/internal/app/volume/service.go index e5bad47..2ca75fa 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -44,3 +44,11 @@ func NewServiceWithRuntime( idGen: idGen, } } + +func (s *Service) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + + s.ids = []string{} + s.volumes = map[string]Volume{} +} diff --git a/internal/app/volume/volume_test.go b/internal/app/volume/volume_test.go index ef97f3b..5b7e68e 100644 --- a/internal/app/volume/volume_test.go +++ b/internal/app/volume/volume_test.go @@ -63,3 +63,18 @@ func (s *VolumeSuite) TestGetVolumeMakesCreatedVolumeAvailable() { 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()) +} From 5d12dcda3de7498d34654f5dca695ae4af8e9b7e Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:45:11 +0900 Subject: [PATCH 14/23] feat(admin): add reset endpoint --- internal/api/admin/reset.go | 8 ++++++++ internal/api/admin/router.go | 10 ++++++++++ internal/api/compute/router.go | 16 +++++++++++++++- internal/api/image/router.go | 16 +++++++++++++++- internal/api/network/router.go | 16 +++++++++++++++- internal/api/router.go | 25 ++++++++++++++++++------ internal/api/router_test.go | 35 ++++++++++++++++++++++++++++++++++ internal/api/volume/router.go | 16 +++++++++++++++- 8 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 internal/api/admin/reset.go 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..e2aacfa 100644 --- a/internal/api/admin/router.go +++ b/internal/api/admin/router.go @@ -7,9 +7,19 @@ import ( ) func NewRouter() http.Handler { + return NewRouterWithReset(func() {}) +} + +func NewRouterWithReset(reset func()) http.Handler { + handler := Handler{reset: reset} router := chi.NewRouter() router.Get("/health", status) router.Get("/ready", status) + router.Post("/reset", handler.resetState) return router } + +type Handler struct { + reset func() +} 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/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..6ad9784 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -10,6 +10,10 @@ 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" + 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" @@ -19,18 +23,27 @@ func NewRouter(cfg config.Config) http.Handler { router := chi.NewRouter() router.Use(requestID) 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.NewRouterWithReset(func() { + computeService.Reset() + imageService.Reset() + networkService.Reset() + volumeService.Reset() + })) 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 } diff --git a/internal/api/router_test.go b/internal/api/router_test.go index 2500fdc..0e9b669 100644 --- a/internal/api/router_test.go +++ b/internal/api/router_test.go @@ -9,6 +9,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 +50,37 @@ 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) +} 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, } } From bb2bd60c54fff70448e734130ae8fa6806c6d7bb Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:46:00 +0900 Subject: [PATCH 15/23] feat(runtime): add request log service --- internal/app/requestlog/service.go | 36 ++++++++++++++++++++ internal/app/requestlog/service_test.go | 45 +++++++++++++++++++++++++ internal/app/requestlog/types.go | 8 +++++ 3 files changed, 89 insertions(+) create mode 100644 internal/app/requestlog/service.go create mode 100644 internal/app/requestlog/service_test.go create mode 100644 internal/app/requestlog/types.go 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 +} From 32189d431c92a971483da7e579fc1abc7891f8f8 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:47:29 +0900 Subject: [PATCH 16/23] feat(admin): expose request recording --- internal/api/admin/request.go | 13 ++++++++++ internal/api/admin/request_dto.go | 28 ++++++++++++++++++++ internal/api/admin/router.go | 17 ++++++++++-- internal/api/router.go | 43 +++++++++++++++++++++++++++++-- internal/api/router_test.go | 34 ++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 internal/api/admin/request.go create mode 100644 internal/api/admin/request_dto.go 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/router.go b/internal/api/admin/router.go index e2aacfa..9a8ea54 100644 --- a/internal/api/admin/router.go +++ b/internal/api/admin/router.go @@ -3,6 +3,7 @@ package admin import ( "net/http" + "github.com/JSYoo5B/SandStack/internal/app/requestlog" "github.com/go-chi/chi/v5" ) @@ -11,15 +12,27 @@ func NewRouter() http.Handler { } func NewRouterWithReset(reset func()) http.Handler { - handler := Handler{reset: reset} + 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() + reset func() + requests *requestlog.Service } diff --git a/internal/api/router.go b/internal/api/router.go index 6ad9784..d97f739 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "strings" "github.com/JSYoo5B/SandStack/internal/api/admin" "github.com/JSYoo5B/SandStack/internal/api/compute" @@ -13,6 +14,7 @@ import ( 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" @@ -22,18 +24,21 @@ import ( func NewRouter(cfg config.Config) http.Handler { router := chi.NewRouter() router.Use(requestID) + requests := requestlog.NewService() + router.Use(recordRequests(requests)) identityHandler := identity.NewHandler(cfg) computeService := appcompute.NewService() imageService := appimage.NewService() networkService := appnetwork.NewService() volumeService := appvolume.NewService() - router.Mount("/_sandstack", admin.NewRouterWithReset(func() { + 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()) @@ -54,3 +59,37 @@ func requestID(next http.Handler) http.Handler { 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/router_test.go b/internal/api/router_test.go index 0e9b669..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" @@ -84,3 +85,36 @@ func (s *RouterSuite) TestMountedSandstackResetClearsState() { 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"` +} From a10ab0efe6ce41b5cd23bba40e5f6401208790e3 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Tue, 23 Jun 2026 01:48:32 +0900 Subject: [PATCH 17/23] refactor(api): split router middleware --- internal/api/middleware.go | 50 ++++++++++++++++++++++++++++++++++++++ internal/api/router.go | 43 -------------------------------- 2 files changed, 50 insertions(+), 43 deletions(-) create mode 100644 internal/api/middleware.go 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/router.go b/internal/api/router.go index d97f739..2426962 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -2,7 +2,6 @@ package api import ( "net/http" - "strings" "github.com/JSYoo5B/SandStack/internal/api/admin" "github.com/JSYoo5B/SandStack/internal/api/compute" @@ -17,7 +16,6 @@ import ( "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" ) @@ -52,44 +50,3 @@ func NewRouter(cfg config.Config) http.Handler { 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) - }) -} - -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) -} From 6042343ce7ffb51bb134f82aabbad9ab467afe57 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:34:38 +0900 Subject: [PATCH 18/23] refactor(image): introduce repository boundary --- internal/app/image/memory_repository.go | 77 +++++++++++++++++++++++++ internal/app/image/repository.go | 9 +++ internal/app/image/service.go | 75 ++++++------------------ 3 files changed, 105 insertions(+), 56 deletions(-) create mode 100644 internal/app/image/memory_repository.go create mode 100644 internal/app/image/repository.go 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 82fcdba..35f107a 100644 --- a/internal/app/image/service.go +++ b/internal/app/image/service.go @@ -2,7 +2,6 @@ package image import ( "errors" - "sync" "time" "github.com/JSYoo5B/SandStack/internal/platform/clock" @@ -12,11 +11,9 @@ import ( var ErrImageNotFound = errors.New("image not found") type Service struct { - mu sync.RWMutex - ids []string - images map[string]Image - clock clock.Clock - idGen idgen.Generator + repository Repository + clock clock.Clock + idGen idgen.Generator } func NewService() *Service { @@ -30,12 +27,19 @@ func NewServiceWithClock(clock clock.Clock) *Service { 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{}, - clock: clock, - idGen: idGen, + repository: repository, + clock: clock, + idGen: idGen, } } @@ -56,62 +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 nil + return s.repository.Delete(id) } func (s *Service) Reset() { - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = []string{} - s.images = map[string]Image{} + s.repository.Reset() } From 63b73fe9fb87de736dbfb763265fb236ed68d315 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:36:48 +0900 Subject: [PATCH 19/23] refactor(network): introduce network repository boundary --- .../app/network/memory_network_repository.go | 90 +++++++++++++++++++ internal/app/network/network.go | 45 +--------- internal/app/network/repository.go | 10 +++ internal/app/network/service.go | 41 +++++---- internal/app/network/subnet.go | 12 +-- 5 files changed, 134 insertions(+), 64 deletions(-) create mode 100644 internal/app/network/memory_network_repository.go create mode 100644 internal/app/network/repository.go 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/network.go b/internal/app/network/network.go index f75501c..19e2f24 100644 --- a/internal/app/network/network.go +++ b/internal/app/network/network.go @@ -24,54 +24,17 @@ func (s *Service) Create(input CreateNetwork) Network { Shared: input.Shared, } - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, network.ID) - s.networks[network.ID] = network - - return network + return s.networkRepository.Create(network) } func (s *Service) List() []Network { - s.mu.RLock() - defer s.mu.RUnlock() - - networks := make([]Network, 0, len(s.ids)) - for _, id := range s.ids { - networks = append(networks, s.networks[id]) - } - - return networks + return s.networkRepository.List() } 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 + return s.networkRepository.Get(id) } 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 + return s.networkRepository.Delete(id) } diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go new file mode 100644 index 0000000..c966ca3 --- /dev/null +++ b/internal/app/network/repository.go @@ -0,0 +1,10 @@ +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() +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 38d9837..991a5d1 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -7,14 +7,13 @@ import ( ) type Service struct { - mu sync.RWMutex - ids []string - networks map[string]Network - subnetIDs []string - subnets map[string]Subnet - portIDs []string - ports map[string]Port - idGen idgen.Generator + mu sync.RWMutex + networkRepository NetworkRepository + subnetIDs []string + subnets map[string]Subnet + portIDs []string + ports map[string]Port + idGen idgen.Generator } func NewService() *Service { @@ -22,14 +21,23 @@ func NewService() *Service { } func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { + return NewServiceWithRepositories( + NewMemoryNetworkRepository(), + idGen, + ) +} + +func NewServiceWithRepositories( + networkRepository NetworkRepository, + idGen idgen.Generator, +) *Service { return &Service{ - ids: []string{}, - networks: map[string]Network{}, - subnetIDs: []string{}, - subnets: map[string]Subnet{}, - portIDs: []string{}, - ports: map[string]Port{}, - idGen: idGen, + networkRepository: networkRepository, + subnetIDs: []string{}, + subnets: map[string]Subnet{}, + portIDs: []string{}, + ports: map[string]Port{}, + idGen: idGen, } } @@ -37,8 +45,7 @@ func (s *Service) Reset() { s.mu.Lock() defer s.mu.Unlock() - s.ids = []string{} - s.networks = map[string]Network{} + s.networkRepository.Reset() s.subnetIDs = []string{} s.subnets = map[string]Subnet{} s.portIDs = []string{} diff --git a/internal/app/network/subnet.go b/internal/app/network/subnet.go index 750f545..18cc23e 100644 --- a/internal/app/network/subnet.go +++ b/internal/app/network/subnet.go @@ -44,10 +44,10 @@ func (s *Service) CreateSubnet(input CreateSubnet) Subnet { s.subnetIDs = append(s.subnetIDs, subnet.ID) s.subnets[subnet.ID] = subnet - network, ok := s.networks[subnet.NetworkID] - if ok { + network, err := s.networkRepository.Get(subnet.NetworkID) + if err == nil { network.Subnets = append(network.Subnets, subnet.ID) - s.networks[subnet.NetworkID] = network + _, _ = s.networkRepository.Update(network) } return subnet @@ -82,10 +82,10 @@ func (s *Service) DeleteSubnet(id string) error { } } - network, ok := s.networks[subnet.NetworkID] - if ok { + network, err := s.networkRepository.Get(subnet.NetworkID) + if err == nil { network.Subnets = removeString(network.Subnets, id) - s.networks[subnet.NetworkID] = network + _, _ = s.networkRepository.Update(network) } return nil From 18389b907cd0248cba892df70b1b2aaf204c412c Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:37:58 +0900 Subject: [PATCH 20/23] refactor(network): introduce subnet repository boundary --- .../app/network/memory_subnet_repository.go | 77 +++++++++++++++++++ internal/app/network/repository.go | 8 ++ internal/app/network/service.go | 11 ++- internal/app/network/subnet.go | 37 ++------- 4 files changed, 98 insertions(+), 35 deletions(-) create mode 100644 internal/app/network/memory_subnet_repository.go 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/repository.go b/internal/app/network/repository.go index c966ca3..179ec92 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -8,3 +8,11 @@ type NetworkRepository interface { Delete(id string) error Reset() } + +type SubnetRepository interface { + Create(subnet Subnet) Subnet + List() []Subnet + Get(id string) (Subnet, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 991a5d1..57db36e 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -9,8 +9,7 @@ import ( type Service struct { mu sync.RWMutex networkRepository NetworkRepository - subnetIDs []string - subnets map[string]Subnet + subnetRepository SubnetRepository portIDs []string ports map[string]Port idGen idgen.Generator @@ -23,18 +22,19 @@ func NewService() *Service { func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { return NewServiceWithRepositories( NewMemoryNetworkRepository(), + NewMemorySubnetRepository(), idGen, ) } func NewServiceWithRepositories( networkRepository NetworkRepository, + subnetRepository SubnetRepository, idGen idgen.Generator, ) *Service { return &Service{ networkRepository: networkRepository, - subnetIDs: []string{}, - subnets: map[string]Subnet{}, + subnetRepository: subnetRepository, portIDs: []string{}, ports: map[string]Port{}, idGen: idGen, @@ -46,8 +46,7 @@ func (s *Service) Reset() { defer s.mu.Unlock() s.networkRepository.Reset() - s.subnetIDs = []string{} - s.subnets = map[string]Subnet{} + s.subnetRepository.Reset() s.portIDs = []string{} s.ports = map[string]Port{} } diff --git a/internal/app/network/subnet.go b/internal/app/network/subnet.go index 18cc23e..931cb5e 100644 --- a/internal/app/network/subnet.go +++ b/internal/app/network/subnet.go @@ -7,15 +7,7 @@ import ( var ErrSubnetNotFound = errors.New("subnet not found") 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 + return s.subnetRepository.List() } func (s *Service) CreateSubnet(input CreateSubnet) Subnet { @@ -41,8 +33,7 @@ func (s *Service) CreateSubnet(input CreateSubnet) Subnet { s.mu.Lock() defer s.mu.Unlock() - s.subnetIDs = append(s.subnetIDs, subnet.ID) - s.subnets[subnet.ID] = subnet + s.subnetRepository.Create(subnet) network, err := s.networkRepository.Get(subnet.NetworkID) if err == nil { @@ -54,32 +45,20 @@ func (s *Service) CreateSubnet(input CreateSubnet) 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 + return s.subnetRepository.Get(id) } func (s *Service) DeleteSubnet(id string) error { s.mu.Lock() defer s.mu.Unlock() - subnet, ok := s.subnets[id] - if !ok { - return ErrSubnetNotFound + subnet, err := s.subnetRepository.Get(id) + if err != nil { + return err } - delete(s.subnets, id) - for index, currentID := range s.subnetIDs { - if currentID == id { - s.subnetIDs = append(s.subnetIDs[:index], s.subnetIDs[index+1:]...) - break - } + if err := s.subnetRepository.Delete(id); err != nil { + return err } network, err := s.networkRepository.Get(subnet.NetworkID) From 91f5c87bfe21cc57fd42e5c14563eba934c37327 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:39:02 +0900 Subject: [PATCH 21/23] refactor(network): introduce port repository boundary --- .../app/network/memory_port_repository.go | 77 +++++++++++++++++++ internal/app/network/port.go | 45 +---------- internal/app/network/repository.go | 8 ++ internal/app/network/service.go | 11 ++- 4 files changed, 94 insertions(+), 47 deletions(-) create mode 100644 internal/app/network/memory_port_repository.go 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/port.go b/internal/app/network/port.go index 08485c5..0cf5efb 100644 --- a/internal/app/network/port.go +++ b/internal/app/network/port.go @@ -7,15 +7,7 @@ import ( var ErrPortNotFound = errors.New("port not found") 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 + return s.portRepository.List() } func (s *Service) CreatePort(input CreatePort) Port { @@ -40,42 +32,13 @@ func (s *Service) CreatePort(input CreatePort) Port { DeviceOwner: input.DeviceOwner, } - s.mu.Lock() - defer s.mu.Unlock() - - s.portIDs = append(s.portIDs, port.ID) - s.ports[port.ID] = port - - return port + return s.portRepository.Create(port) } func (s *Service) GetPort(id string) (Port, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - port, ok := s.ports[id] - if !ok { - return Port{}, ErrPortNotFound - } - - return port, nil + return s.portRepository.Get(id) } func (s *Service) DeletePort(id string) error { - s.mu.Lock() - defer s.mu.Unlock() - - if _, ok := s.ports[id]; !ok { - return ErrPortNotFound - } - - delete(s.ports, id) - for index, currentID := range s.portIDs { - if currentID == id { - s.portIDs = append(s.portIDs[:index], s.portIDs[index+1:]...) - break - } - } - - return nil + return s.portRepository.Delete(id) } diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index 179ec92..7d414e5 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -16,3 +16,11 @@ type SubnetRepository interface { 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 57db36e..1093ccd 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -10,8 +10,7 @@ type Service struct { mu sync.RWMutex networkRepository NetworkRepository subnetRepository SubnetRepository - portIDs []string - ports map[string]Port + portRepository PortRepository idGen idgen.Generator } @@ -23,6 +22,7 @@ func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { return NewServiceWithRepositories( NewMemoryNetworkRepository(), NewMemorySubnetRepository(), + NewMemoryPortRepository(), idGen, ) } @@ -30,13 +30,13 @@ func NewServiceWithIDGenerator(idGen idgen.Generator) *Service { func NewServiceWithRepositories( networkRepository NetworkRepository, subnetRepository SubnetRepository, + portRepository PortRepository, idGen idgen.Generator, ) *Service { return &Service{ networkRepository: networkRepository, subnetRepository: subnetRepository, - portIDs: []string{}, - ports: map[string]Port{}, + portRepository: portRepository, idGen: idGen, } } @@ -47,6 +47,5 @@ func (s *Service) Reset() { s.networkRepository.Reset() s.subnetRepository.Reset() - s.portIDs = []string{} - s.ports = map[string]Port{} + s.portRepository.Reset() } From bc8d8708ef7988438d308b6b7d0419887eede649 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:40:33 +0900 Subject: [PATCH 22/23] refactor(compute): introduce server repository boundary --- .../app/compute/memory_server_repository.go | 90 +++++++++++++++++++ internal/app/compute/repository.go | 10 +++ internal/app/compute/server.go | 60 +++---------- internal/app/compute/service.go | 37 ++++---- 4 files changed, 132 insertions(+), 65 deletions(-) create mode 100644 internal/app/compute/memory_server_repository.go create mode 100644 internal/app/compute/repository.go 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 index 4b04f11..7129180 100644 --- a/internal/app/compute/server.go +++ b/internal/app/compute/server.go @@ -10,14 +10,9 @@ var ErrServerNotFound = errors.New("server not found") const serverTimestampFormat = time.RFC3339 func (s *Service) ListServers() []Server { - s.mu.Lock() - defer s.mu.Unlock() - - s.activateServersLocked() - - servers := make([]Server, 0, len(s.ids)) - for _, id := range s.ids { - servers = append(servers, s.servers[id]) + servers := s.serverRepository.List() + for index, server := range servers { + servers[index] = s.activateServer(server) } return servers @@ -39,56 +34,23 @@ func (s *Service) CreateServer(input CreateServer) Server { Metadata: input.Metadata, } - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, server.ID) - s.servers[server.ID] = server - - return server + return s.serverRepository.Create(server) } func (s *Service) GetServer(id string) (Server, error) { - s.mu.Lock() - defer s.mu.Unlock() - - server, ok := s.servers[id] - if !ok { - return Server{}, ErrServerNotFound + server, err := s.serverRepository.Get(id) + if err != nil { + return Server{}, err } - server = s.activateServerLocked(server) - s.servers[id] = server - - return server, nil + return s.activateServer(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 + return s.serverRepository.Delete(id) } -func (s *Service) activateServersLocked() { - for _, id := range s.ids { - s.servers[id] = s.activateServerLocked(s.servers[id]) - } -} - -func (s *Service) activateServerLocked(server Server) Server { +func (s *Service) activateServer(server Server) Server { if server.Status != "BUILD" { return server } @@ -97,5 +59,7 @@ func (s *Service) activateServerLocked(server Server) Server { server.Progress = 100 server.UpdatedAt = s.clock.Now().UTC().Format(serverTimestampFormat) + _, _ = s.serverRepository.Update(server) + return server } diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index ac5e536..bcf8463 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -1,19 +1,15 @@ package compute import ( - "sync" - "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) type Service struct { - flavors []Flavor - mu sync.RWMutex - ids []string - servers map[string]Server - clock clock.Clock - idGen idgen.Generator + flavors []Flavor + serverRepository ServerRepository + clock clock.Clock + idGen idgen.Generator } func NewService() *Service { @@ -27,6 +23,18 @@ func NewServiceWithClock(clock clock.Clock) *Service { 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{ @@ -44,17 +52,12 @@ func NewServiceWithRuntime( ExtraSpecs: map[string]string{}, }, }, - ids: []string{}, - servers: map[string]Server{}, - clock: clock, - idGen: idGen, + serverRepository: serverRepository, + clock: clock, + idGen: idGen, } } func (s *Service) Reset() { - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = []string{} - s.servers = map[string]Server{} + s.serverRepository.Reset() } From 6a401976f3514ece1367995b1cce41f064749edc Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 02:41:45 +0900 Subject: [PATCH 23/23] refactor(volume): introduce volume repository boundary --- internal/app/volume/memory_repository.go | 90 ++++++++++++++++++++++++ internal/app/volume/repository.go | 10 +++ internal/app/volume/service.go | 23 +++--- internal/app/volume/volume.go | 60 ++++------------ 4 files changed, 123 insertions(+), 60 deletions(-) create mode 100644 internal/app/volume/memory_repository.go create mode 100644 internal/app/volume/repository.go 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 2ca75fa..3076014 100644 --- a/internal/app/volume/service.go +++ b/internal/app/volume/service.go @@ -1,16 +1,12 @@ package volume import ( - "sync" - "github.com/JSYoo5B/SandStack/internal/platform/clock" "github.com/JSYoo5B/SandStack/internal/platform/idgen" ) type Service struct { - mu sync.RWMutex - ids []string - volumes map[string]Volume + repository Repository volumeTypes []VolumeType clock clock.Clock idGen idgen.Generator @@ -27,10 +23,17 @@ func NewServiceWithClock(clock clock.Clock) *Service { 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", @@ -46,9 +49,5 @@ func NewServiceWithRuntime( } func (s *Service) Reset() { - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = []string{} - s.volumes = map[string]Volume{} + s.repository.Reset() } diff --git a/internal/app/volume/volume.go b/internal/app/volume/volume.go index 3c4374b..535586e 100644 --- a/internal/app/volume/volume.go +++ b/internal/app/volume/volume.go @@ -23,70 +23,32 @@ func (s *Service) Create(input CreateVolume) Volume { Bootable: "false", } - s.mu.Lock() - defer s.mu.Unlock() - - s.ids = append(s.ids, volume.ID) - s.volumes[volume.ID] = volume - - return volume + return s.repository.Create(volume) } func (s *Service) List() []Volume { - s.mu.Lock() - defer s.mu.Unlock() - - s.makeVolumesAvailableLocked() - - volumes := make([]Volume, 0, len(s.ids)) - for _, id := range s.ids { - volumes = append(volumes, s.volumes[id]) + volumes := s.repository.List() + for index, volume := range volumes { + volumes[index] = s.makeVolumeAvailable(volume) } return volumes } func (s *Service) Get(id string) (Volume, error) { - s.mu.Lock() - defer s.mu.Unlock() - - volume, ok := s.volumes[id] - if !ok { - return Volume{}, ErrVolumeNotFound + volume, err := s.repository.Get(id) + if err != nil { + return Volume{}, err } - volume = s.makeVolumeAvailableLocked(volume) - s.volumes[id] = volume - - return volume, nil + return s.makeVolumeAvailable(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 + return s.repository.Delete(id) } -func (s *Service) makeVolumesAvailableLocked() { - for _, id := range s.ids { - s.volumes[id] = s.makeVolumeAvailableLocked(s.volumes[id]) - } -} - -func (s *Service) makeVolumeAvailableLocked(volume Volume) Volume { +func (s *Service) makeVolumeAvailable(volume Volume) Volume { if volume.Status != "creating" { return volume } @@ -94,5 +56,7 @@ func (s *Service) makeVolumeAvailableLocked(volume Volume) Volume { volume.Status = "available" volume.UpdatedAt = s.clock.Now().UTC().Format(timestampFormat) + _, _ = s.repository.Update(volume) + return volume }