From 63ef933825597485b2f0c829e81b7404b745c45f Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:22:29 +0900 Subject: [PATCH 1/5] feat(network): add security group endpoints --- internal/api/network/router.go | 5 + internal/api/network/security_group.go | 68 +++++++++++ internal/api/network/security_group_dto.go | 111 ++++++++++++++++++ internal/api/network/security_group_test.go | 109 +++++++++++++++++ internal/api/router.go | 1 + internal/app/network/repository.go | 8 ++ internal/app/network/security_group.go | 37 ++++++ internal/app/network/service.go | 22 ++-- internal/app/network/service_test.go | 15 +++ internal/app/network/types.go | 32 +++++ .../memory_security_group_repository.go | 85 ++++++++++++++ 11 files changed, 484 insertions(+), 9 deletions(-) create mode 100644 internal/api/network/security_group.go create mode 100644 internal/api/network/security_group_dto.go create mode 100644 internal/api/network/security_group_test.go create mode 100644 internal/app/network/security_group.go create mode 100644 internal/store/network/memory_security_group_repository.go diff --git a/internal/api/network/router.go b/internal/api/network/router.go index e340b0a..08436b3 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -33,6 +33,7 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), idgen.Random(), ), ) @@ -63,6 +64,10 @@ func (h Handler) Router() http.Handler { router.Post("/ports", h.createPort) router.Get("/ports/{port_id}", h.getPort) router.Delete("/ports/{port_id}", h.deletePort) + router.Get("/security-groups", h.listSecurityGroups) + router.Post("/security-groups", h.createSecurityGroup) + router.Get("/security-groups/{security_group_id}", h.getSecurityGroup) + router.Delete("/security-groups/{security_group_id}", h.deleteSecurityGroup) return router } diff --git a/internal/api/network/security_group.go b/internal/api/network/security_group.go new file mode 100644 index 0000000..7111b1f --- /dev/null +++ b/internal/api/network/security_group.go @@ -0,0 +1,68 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listSecurityGroups(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, securityGroupListResponse{ + SecurityGroups: toSecurityGroupDocuments( + h.service.ListSecurityGroups(), + ), + }) +} + +func (h Handler) createSecurityGroup(w http.ResponseWriter, r *http.Request) { + var request createSecurityGroupRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + securityGroup := h.service.CreateSecurityGroup( + request.createSecurityGroup(), + ) + respond.JSON(w, http.StatusCreated, securityGroupResponse{ + SecurityGroup: toSecurityGroupDocument(securityGroup), + }) +} + +func (h Handler) getSecurityGroup(w http.ResponseWriter, r *http.Request) { + securityGroup, err := h.service.GetSecurityGroup( + chi.URLParam(r, "security_group_id"), + ) + if errors.Is(err, appnetwork.ErrSecurityGroupNotFound) { + respond.Error(w, http.StatusNotFound, "security group not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "security group lookup failed") + return + } + + respond.JSON(w, http.StatusOK, securityGroupResponse{ + SecurityGroup: toSecurityGroupDocument(securityGroup), + }) +} + +func (h Handler) deleteSecurityGroup(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteSecurityGroup( + chi.URLParam(r, "security_group_id"), + ) + if errors.Is(err, appnetwork.ErrSecurityGroupNotFound) { + respond.Error(w, http.StatusNotFound, "security group not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "security group delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/security_group_dto.go b/internal/api/network/security_group_dto.go new file mode 100644 index 0000000..909d2c6 --- /dev/null +++ b/internal/api/network/security_group_dto.go @@ -0,0 +1,111 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type createSecurityGroupRequest struct { + SecurityGroup createSecurityGroupDocument `json:"security_group"` +} + +type createSecurityGroupDocument struct { + Name string `json:"name"` + Description string `json:"description"` + Stateful *bool `json:"stateful"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` +} + +func (r createSecurityGroupRequest) createSecurityGroup() appnetwork.CreateSecurityGroup { + projectID := r.SecurityGroup.ProjectID + if projectID == "" { + projectID = r.SecurityGroup.TenantID + } + + return appnetwork.CreateSecurityGroup{ + Name: r.SecurityGroup.Name, + Description: r.SecurityGroup.Description, + Stateful: r.SecurityGroup.Stateful, + ProjectID: projectID, + } +} + +type securityGroupListResponse struct { + SecurityGroups []securityGroupDocument `json:"security_groups"` +} + +type securityGroupResponse struct { + SecurityGroup securityGroupDocument `json:"security_group"` +} + +type securityGroupDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Stateful bool `json:"stateful"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + Rules []securityGroupRuleDocument `json:"security_group_rules"` + Tags []string `json:"tags"` +} + +type securityGroupRuleDocument struct { + ID string `json:"id"` + Direction string `json:"direction"` + EtherType string `json:"ethertype"` + Protocol string `json:"protocol"` + PortRangeMin int `json:"port_range_min"` + PortRangeMax int `json:"port_range_max"` + RemoteIPPrefix string `json:"remote_ip_prefix"` + RemoteGroupID string `json:"remote_group_id"` + SecurityGroupID string `json:"security_group_id"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` +} + +func toSecurityGroupDocuments( + securityGroups []appnetwork.SecurityGroup, +) []securityGroupDocument { + documents := make([]securityGroupDocument, 0, len(securityGroups)) + for _, securityGroup := range securityGroups { + documents = append(documents, toSecurityGroupDocument(securityGroup)) + } + + return documents +} + +func toSecurityGroupDocument( + securityGroup appnetwork.SecurityGroup, +) securityGroupDocument { + return securityGroupDocument{ + ID: securityGroup.ID, + Name: securityGroup.Name, + Description: securityGroup.Description, + Stateful: securityGroup.Stateful, + TenantID: securityGroup.TenantID, + ProjectID: securityGroup.ProjectID, + Rules: toSecurityGroupRuleDocuments(securityGroup.Rules), + Tags: securityGroup.Tags, + } +} + +func toSecurityGroupRuleDocuments( + rules []appnetwork.SecurityGroupRule, +) []securityGroupRuleDocument { + documents := make([]securityGroupRuleDocument, 0, len(rules)) + for _, rule := range rules { + documents = append(documents, securityGroupRuleDocument{ + ID: rule.ID, + Direction: rule.Direction, + EtherType: rule.EtherType, + Protocol: rule.Protocol, + PortRangeMin: rule.PortRangeMin, + PortRangeMax: rule.PortRangeMax, + RemoteIPPrefix: rule.RemoteIPPrefix, + RemoteGroupID: rule.RemoteGroupID, + SecurityGroupID: rule.SecurityGroupID, + TenantID: rule.TenantID, + ProjectID: rule.ProjectID, + }) + } + + return documents +} diff --git a/internal/api/network/security_group_test.go b/internal/api/network/security_group_test.go new file mode 100644 index 0000000..156ff79 --- /dev/null +++ b/internal/api/network/security_group_test.go @@ -0,0 +1,109 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" + "github.com/stretchr/testify/suite" +) + +type SecurityGroupSuite struct { + suite.Suite + server *httptest.Server +} + +func TestSecurityGroupSuite(t *testing.T) { + suite.Run(t, new(SecurityGroupSuite)) +} + +func (s *SecurityGroupSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *SecurityGroupSuite) TearDownTest() { + s.server.Close() +} + +func (s *SecurityGroupSuite) TestListSecurityGroups() { + list := s.listSecurityGroups() + + s.Assert().Empty(list) +} + +func (s *SecurityGroupSuite) TestCreateSecurityGroupThenListSecurityGroups() { + created := s.createSecurityGroup("web") + + list := s.listSecurityGroups() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("web", created.Name) + s.Assert().Equal("security group for web", created.Description) + s.Assert().True(created.Stateful) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("web", list[0].Name) +} + +func (s *SecurityGroupSuite) TestGetSecurityGroup() { + created := s.createSecurityGroup("web") + + found, err := groups.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("web", found.Name) +} + +func (s *SecurityGroupSuite) TestDeleteSecurityGroup() { + created := s.createSecurityGroup("web") + + err := groups.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listSecurityGroups() + + s.Assert().Empty(list) +} + +func (s *SecurityGroupSuite) listSecurityGroups() []groups.SecGroup { + pages, err := groups.List( + testhelper.ServiceClient(s.server.URL), + groups.ListOpts{}, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := groups.ExtractGroups(pages) + s.Require().NoError(err) + + return list +} + +func (s *SecurityGroupSuite) createSecurityGroup(name string) *groups.SecGroup { + created, err := groups.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + groups.CreateOpts{ + Name: name, + Description: "security group for " + name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/router.go b/internal/api/router.go index 541c274..e0ab741 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -61,6 +61,7 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), idgen.Random(), ) volumeService := appvolume.NewServiceWithRuntime( diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index 7d414e5..334d21a 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -24,3 +24,11 @@ type PortRepository interface { Delete(id string) error Reset() } + +type SecurityGroupRepository interface { + Create(securityGroup SecurityGroup) SecurityGroup + List() []SecurityGroup + Get(id string) (SecurityGroup, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/security_group.go b/internal/app/network/security_group.go new file mode 100644 index 0000000..7b8efbf --- /dev/null +++ b/internal/app/network/security_group.go @@ -0,0 +1,37 @@ +package network + +import "errors" + +var ErrSecurityGroupNotFound = errors.New("security group not found") + +func (s *Service) CreateSecurityGroup(input CreateSecurityGroup) SecurityGroup { + stateful := true + if input.Stateful != nil { + stateful = *input.Stateful + } + + securityGroup := SecurityGroup{ + ID: "sg-" + s.idGen.Hex(16), + Name: input.Name, + Description: input.Description, + Stateful: stateful, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + Rules: []SecurityGroupRule{}, + Tags: []string{}, + } + + return s.securityGroupRepository.Create(securityGroup) +} + +func (s *Service) ListSecurityGroups() []SecurityGroup { + return s.securityGroupRepository.List() +} + +func (s *Service) GetSecurityGroup(id string) (SecurityGroup, error) { + return s.securityGroupRepository.Get(id) +} + +func (s *Service) DeleteSecurityGroup(id string) error { + return s.securityGroupRepository.Delete(id) +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index c739476..b4a189f 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -7,24 +7,27 @@ import ( ) type Service struct { - mu sync.RWMutex - networkRepository NetworkRepository - subnetRepository SubnetRepository - portRepository PortRepository - idGen idgen.Generator + mu sync.RWMutex + networkRepository NetworkRepository + subnetRepository SubnetRepository + portRepository PortRepository + securityGroupRepository SecurityGroupRepository + idGen idgen.Generator } func NewServiceWithRepositories( networkRepository NetworkRepository, subnetRepository SubnetRepository, portRepository PortRepository, + securityGroupRepository SecurityGroupRepository, idGen idgen.Generator, ) *Service { return &Service{ - networkRepository: networkRepository, - subnetRepository: subnetRepository, - portRepository: portRepository, - idGen: idGen, + networkRepository: networkRepository, + subnetRepository: subnetRepository, + portRepository: portRepository, + securityGroupRepository: securityGroupRepository, + idGen: idGen, } } @@ -35,4 +38,5 @@ func (s *Service) Reset() { s.networkRepository.Reset() s.subnetRepository.Reset() s.portRepository.Reset() + s.securityGroupRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index 69bce13..b89e9ec 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -48,17 +48,31 @@ func (s *ServiceSuite) TestCreatePortUsesInjectedIDGenerator() { s.Assert().Equal("fa:16:3e:port-id", created.MACAddress) } +func (s *ServiceSuite) TestCreateSecurityGroupUsesInjectedIDGenerator() { + service := newService(idgen.Fixed("security-group-id")) + + created := service.CreateSecurityGroup(network.CreateSecurityGroup{ + Name: "web", + }) + + s.Assert().Equal("sg-security-group-id", created.ID) + s.Assert().Equal("web", created.Name) + s.Assert().True(created.Stateful) +} + func (s *ServiceSuite) TestResetClearsNetworkResources() { service := newService(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.CreateSecurityGroup(network.CreateSecurityGroup{Name: "default"}) service.Reset() s.Assert().Empty(service.List()) s.Assert().Empty(service.ListSubnets()) s.Assert().Empty(service.ListPorts()) + s.Assert().Empty(service.ListSecurityGroups()) } func newService(idGen idgen.Generator) *network.Service { @@ -66,6 +80,7 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 94d1a09..4fee492 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -88,3 +88,35 @@ type FixedIP struct { SubnetID string IPAddress string } + +type SecurityGroup struct { + ID string + Name string + Description string + Stateful bool + TenantID string + ProjectID string + Rules []SecurityGroupRule + Tags []string +} + +type CreateSecurityGroup struct { + Name string + Description string + Stateful *bool + ProjectID string +} + +type SecurityGroupRule struct { + ID string + Direction string + EtherType string + Protocol string + PortRangeMin int + PortRangeMax int + RemoteIPPrefix string + RemoteGroupID string + SecurityGroupID string + TenantID string + ProjectID string +} diff --git a/internal/store/network/memory_security_group_repository.go b/internal/store/network/memory_security_group_repository.go new file mode 100644 index 0000000..85855d7 --- /dev/null +++ b/internal/store/network/memory_security_group_repository.go @@ -0,0 +1,85 @@ +package network + +import ( + "sync" + + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +) + +type MemorySecurityGroupRepository struct { + mu sync.RWMutex + ids []string + securityGroups map[string]appnetwork.SecurityGroup +} + +func NewMemorySecurityGroupRepository() *MemorySecurityGroupRepository { + return &MemorySecurityGroupRepository{ + ids: []string{}, + securityGroups: map[string]appnetwork.SecurityGroup{}, + } +} + +func (r *MemorySecurityGroupRepository) Create( + securityGroup appnetwork.SecurityGroup, +) appnetwork.SecurityGroup { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, securityGroup.ID) + r.securityGroups[securityGroup.ID] = securityGroup + + return securityGroup +} + +func (r *MemorySecurityGroupRepository) List() []appnetwork.SecurityGroup { + r.mu.RLock() + defer r.mu.RUnlock() + + securityGroups := make([]appnetwork.SecurityGroup, 0, len(r.ids)) + for _, id := range r.ids { + securityGroups = append(securityGroups, r.securityGroups[id]) + } + + return securityGroups +} + +func (r *MemorySecurityGroupRepository) Get( + id string, +) (appnetwork.SecurityGroup, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + securityGroup, ok := r.securityGroups[id] + if !ok { + return appnetwork.SecurityGroup{}, appnetwork.ErrSecurityGroupNotFound + } + + return securityGroup, nil +} + +func (r *MemorySecurityGroupRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.securityGroups[id]; !ok { + return appnetwork.ErrSecurityGroupNotFound + } + + delete(r.securityGroups, 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 *MemorySecurityGroupRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.securityGroups = map[string]appnetwork.SecurityGroup{} +} From f7c044ac8073610cedb648cd4ca0d418f8e6cbc4 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:25:17 +0900 Subject: [PATCH 2/5] feat(network): add security group rule endpoints --- internal/api/network/router.go | 5 + internal/api/network/security_group_dto.go | 48 +++--- internal/api/network/security_group_rule.go | 77 ++++++++++ .../api/network/security_group_rule_dto.go | 71 +++++++++ .../api/network/security_group_rule_test.go | 137 ++++++++++++++++++ internal/api/router.go | 1 + internal/app/network/repository.go | 8 + internal/app/network/security_group_rule.go | 49 +++++++ internal/app/network/service.go | 4 + internal/app/network/service_test.go | 37 ++++- internal/app/network/types.go | 38 +++-- .../memory_security_group_rule_repository.go | 85 +++++++++++ 12 files changed, 526 insertions(+), 34 deletions(-) create mode 100644 internal/api/network/security_group_rule.go create mode 100644 internal/api/network/security_group_rule_dto.go create mode 100644 internal/api/network/security_group_rule_test.go create mode 100644 internal/app/network/security_group_rule.go create mode 100644 internal/store/network/memory_security_group_rule_repository.go diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 08436b3..2396c4a 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -34,6 +34,7 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), idgen.Random(), ), ) @@ -68,6 +69,10 @@ func (h Handler) Router() http.Handler { router.Post("/security-groups", h.createSecurityGroup) router.Get("/security-groups/{security_group_id}", h.getSecurityGroup) router.Delete("/security-groups/{security_group_id}", h.deleteSecurityGroup) + router.Get("/security-group-rules", h.listSecurityGroupRules) + router.Post("/security-group-rules", h.createSecurityGroupRule) + router.Get("/security-group-rules/{security_group_rule_id}", h.getSecurityGroupRule) + router.Delete("/security-group-rules/{security_group_rule_id}", h.deleteSecurityGroupRule) return router } diff --git a/internal/api/network/security_group_dto.go b/internal/api/network/security_group_dto.go index 909d2c6..697bb35 100644 --- a/internal/api/network/security_group_dto.go +++ b/internal/api/network/security_group_dto.go @@ -48,17 +48,19 @@ type securityGroupDocument struct { } type securityGroupRuleDocument struct { - ID string `json:"id"` - Direction string `json:"direction"` - EtherType string `json:"ethertype"` - Protocol string `json:"protocol"` - PortRangeMin int `json:"port_range_min"` - PortRangeMax int `json:"port_range_max"` - RemoteIPPrefix string `json:"remote_ip_prefix"` - RemoteGroupID string `json:"remote_group_id"` - SecurityGroupID string `json:"security_group_id"` - TenantID string `json:"tenant_id"` - ProjectID string `json:"project_id"` + ID string `json:"id"` + Direction string `json:"direction"` + Description string `json:"description"` + EtherType string `json:"ethertype"` + Protocol string `json:"protocol"` + PortRangeMin int `json:"port_range_min"` + PortRangeMax int `json:"port_range_max"` + RemoteAddressGroupID string `json:"remote_address_group_id"` + RemoteIPPrefix string `json:"remote_ip_prefix"` + RemoteGroupID string `json:"remote_group_id"` + SecurityGroupID string `json:"security_group_id"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` } func toSecurityGroupDocuments( @@ -93,17 +95,19 @@ func toSecurityGroupRuleDocuments( documents := make([]securityGroupRuleDocument, 0, len(rules)) for _, rule := range rules { documents = append(documents, securityGroupRuleDocument{ - ID: rule.ID, - Direction: rule.Direction, - EtherType: rule.EtherType, - Protocol: rule.Protocol, - PortRangeMin: rule.PortRangeMin, - PortRangeMax: rule.PortRangeMax, - RemoteIPPrefix: rule.RemoteIPPrefix, - RemoteGroupID: rule.RemoteGroupID, - SecurityGroupID: rule.SecurityGroupID, - TenantID: rule.TenantID, - ProjectID: rule.ProjectID, + ID: rule.ID, + Direction: rule.Direction, + Description: rule.Description, + EtherType: rule.EtherType, + Protocol: rule.Protocol, + PortRangeMin: rule.PortRangeMin, + PortRangeMax: rule.PortRangeMax, + RemoteAddressGroupID: rule.RemoteAddressGroupID, + RemoteIPPrefix: rule.RemoteIPPrefix, + RemoteGroupID: rule.RemoteGroupID, + SecurityGroupID: rule.SecurityGroupID, + TenantID: rule.TenantID, + ProjectID: rule.ProjectID, }) } diff --git a/internal/api/network/security_group_rule.go b/internal/api/network/security_group_rule.go new file mode 100644 index 0000000..17f4e17 --- /dev/null +++ b/internal/api/network/security_group_rule.go @@ -0,0 +1,77 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listSecurityGroupRules(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, securityGroupRuleListResponse{ + SecurityGroupRules: toSecurityGroupRuleDocuments( + h.service.ListSecurityGroupRules(), + ), + }) +} + +func (h Handler) createSecurityGroupRule(w http.ResponseWriter, r *http.Request) { + var request createSecurityGroupRuleRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + rule, err := h.service.CreateSecurityGroupRule( + request.createSecurityGroupRule(), + ) + if errors.Is(err, appnetwork.ErrSecurityGroupNotFound) { + respond.Error(w, http.StatusNotFound, "security group not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "security group rule create failed") + return + } + + respond.JSON(w, http.StatusCreated, securityGroupRuleResponse{ + SecurityGroupRule: toSecurityGroupRuleDocument(rule), + }) +} + +func (h Handler) getSecurityGroupRule(w http.ResponseWriter, r *http.Request) { + rule, err := h.service.GetSecurityGroupRule( + chi.URLParam(r, "security_group_rule_id"), + ) + if errors.Is(err, appnetwork.ErrSecurityGroupRuleNotFound) { + respond.Error(w, http.StatusNotFound, "security group rule not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "security group rule lookup failed") + return + } + + respond.JSON(w, http.StatusOK, securityGroupRuleResponse{ + SecurityGroupRule: toSecurityGroupRuleDocument(rule), + }) +} + +func (h Handler) deleteSecurityGroupRule(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteSecurityGroupRule( + chi.URLParam(r, "security_group_rule_id"), + ) + if errors.Is(err, appnetwork.ErrSecurityGroupRuleNotFound) { + respond.Error(w, http.StatusNotFound, "security group rule not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "security group rule delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/security_group_rule_dto.go b/internal/api/network/security_group_rule_dto.go new file mode 100644 index 0000000..cd0e9f5 --- /dev/null +++ b/internal/api/network/security_group_rule_dto.go @@ -0,0 +1,71 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type createSecurityGroupRuleRequest struct { + SecurityGroupRule createSecurityGroupRuleDocument `json:"security_group_rule"` +} + +type createSecurityGroupRuleDocument struct { + Direction string `json:"direction"` + Description string `json:"description"` + EtherType string `json:"ethertype"` + Protocol string `json:"protocol"` + PortRangeMin int `json:"port_range_min"` + PortRangeMax int `json:"port_range_max"` + RemoteAddressGroupID string `json:"remote_address_group_id"` + RemoteIPPrefix string `json:"remote_ip_prefix"` + RemoteGroupID string `json:"remote_group_id"` + SecurityGroupID string `json:"security_group_id"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` +} + +func (r createSecurityGroupRuleRequest) createSecurityGroupRule() appnetwork.CreateSecurityGroupRule { + projectID := r.SecurityGroupRule.ProjectID + if projectID == "" { + projectID = r.SecurityGroupRule.TenantID + } + + return appnetwork.CreateSecurityGroupRule{ + Direction: r.SecurityGroupRule.Direction, + Description: r.SecurityGroupRule.Description, + EtherType: r.SecurityGroupRule.EtherType, + Protocol: r.SecurityGroupRule.Protocol, + PortRangeMin: r.SecurityGroupRule.PortRangeMin, + PortRangeMax: r.SecurityGroupRule.PortRangeMax, + RemoteAddressGroupID: r.SecurityGroupRule.RemoteAddressGroupID, + RemoteIPPrefix: r.SecurityGroupRule.RemoteIPPrefix, + RemoteGroupID: r.SecurityGroupRule.RemoteGroupID, + SecurityGroupID: r.SecurityGroupRule.SecurityGroupID, + ProjectID: projectID, + } +} + +type securityGroupRuleListResponse struct { + SecurityGroupRules []securityGroupRuleDocument `json:"security_group_rules"` +} + +type securityGroupRuleResponse struct { + SecurityGroupRule securityGroupRuleDocument `json:"security_group_rule"` +} + +func toSecurityGroupRuleDocument( + rule appnetwork.SecurityGroupRule, +) securityGroupRuleDocument { + return securityGroupRuleDocument{ + ID: rule.ID, + Direction: rule.Direction, + Description: rule.Description, + EtherType: rule.EtherType, + Protocol: rule.Protocol, + PortRangeMin: rule.PortRangeMin, + PortRangeMax: rule.PortRangeMax, + RemoteAddressGroupID: rule.RemoteAddressGroupID, + RemoteIPPrefix: rule.RemoteIPPrefix, + RemoteGroupID: rule.RemoteGroupID, + SecurityGroupID: rule.SecurityGroupID, + TenantID: rule.TenantID, + ProjectID: rule.ProjectID, + } +} diff --git a/internal/api/network/security_group_rule_test.go b/internal/api/network/security_group_rule_test.go new file mode 100644 index 0000000..37b485e --- /dev/null +++ b/internal/api/network/security_group_rule_test.go @@ -0,0 +1,137 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/groups" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/security/rules" + "github.com/stretchr/testify/suite" +) + +type SecurityGroupRuleSuite struct { + suite.Suite + server *httptest.Server +} + +func TestSecurityGroupRuleSuite(t *testing.T) { + suite.Run(t, new(SecurityGroupRuleSuite)) +} + +func (s *SecurityGroupRuleSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *SecurityGroupRuleSuite) TearDownTest() { + s.server.Close() +} + +func (s *SecurityGroupRuleSuite) TestListSecurityGroupRules() { + list := s.listSecurityGroupRules() + + s.Assert().Empty(list) +} + +func (s *SecurityGroupRuleSuite) TestCreateSecurityGroupRuleThenListRules() { + securityGroup := s.createSecurityGroup("web") + created := s.createSecurityGroupRule(securityGroup.ID) + + list := s.listSecurityGroupRules() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal(securityGroup.ID, created.SecGroupID) + s.Assert().Equal("ingress", created.Direction) + s.Assert().Equal("IPv4", created.EtherType) + s.Assert().Equal("tcp", created.Protocol) + s.Assert().Equal("0.0.0.0/0", created.RemoteIPPrefix) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) +} + +func (s *SecurityGroupRuleSuite) TestGetSecurityGroupRule() { + securityGroup := s.createSecurityGroup("web") + created := s.createSecurityGroupRule(securityGroup.ID) + + found, err := rules.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal(securityGroup.ID, found.SecGroupID) +} + +func (s *SecurityGroupRuleSuite) TestDeleteSecurityGroupRule() { + securityGroup := s.createSecurityGroup("web") + created := s.createSecurityGroupRule(securityGroup.ID) + + err := rules.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listSecurityGroupRules() + + s.Assert().Empty(list) +} + +func (s *SecurityGroupRuleSuite) listSecurityGroupRules() []rules.SecGroupRule { + pages, err := rules.List( + testhelper.ServiceClient(s.server.URL), + rules.ListOpts{}, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := rules.ExtractRules(pages) + s.Require().NoError(err) + + return list +} + +func (s *SecurityGroupRuleSuite) createSecurityGroup(name string) *groups.SecGroup { + created, err := groups.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + groups.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *SecurityGroupRuleSuite) createSecurityGroupRule( + securityGroupID string, +) *rules.SecGroupRule { + created, err := rules.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + rules.CreateOpts{ + Direction: rules.DirIngress, + EtherType: rules.EtherType4, + Protocol: rules.ProtocolTCP, + PortRangeMin: 80, + PortRangeMax: 80, + RemoteIPPrefix: "0.0.0.0/0", + SecGroupID: securityGroupID, + ProjectID: "demo", + Description: "allow web", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/router.go b/internal/api/router.go index e0ab741..df81d93 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -62,6 +62,7 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), idgen.Random(), ) volumeService := appvolume.NewServiceWithRuntime( diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index 334d21a..c07fb40 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -32,3 +32,11 @@ type SecurityGroupRepository interface { Delete(id string) error Reset() } + +type SecurityGroupRuleRepository interface { + Create(rule SecurityGroupRule) SecurityGroupRule + List() []SecurityGroupRule + Get(id string) (SecurityGroupRule, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/security_group_rule.go b/internal/app/network/security_group_rule.go new file mode 100644 index 0000000..5dc7fbc --- /dev/null +++ b/internal/app/network/security_group_rule.go @@ -0,0 +1,49 @@ +package network + +import "errors" + +var ErrSecurityGroupRuleNotFound = errors.New("security group rule not found") + +func (s *Service) CreateSecurityGroupRule( + input CreateSecurityGroupRule, +) (SecurityGroupRule, error) { + securityGroup, err := s.securityGroupRepository.Get(input.SecurityGroupID) + if err != nil { + return SecurityGroupRule{}, err + } + + projectID := input.ProjectID + if projectID == "" { + projectID = securityGroup.ProjectID + } + + rule := SecurityGroupRule{ + ID: "sgr-" + s.idGen.Hex(16), + Direction: input.Direction, + Description: input.Description, + EtherType: input.EtherType, + Protocol: input.Protocol, + PortRangeMin: input.PortRangeMin, + PortRangeMax: input.PortRangeMax, + RemoteAddressGroupID: input.RemoteAddressGroupID, + RemoteIPPrefix: input.RemoteIPPrefix, + RemoteGroupID: input.RemoteGroupID, + SecurityGroupID: input.SecurityGroupID, + TenantID: projectID, + ProjectID: projectID, + } + + return s.securityRuleRepository.Create(rule), nil +} + +func (s *Service) ListSecurityGroupRules() []SecurityGroupRule { + return s.securityRuleRepository.List() +} + +func (s *Service) GetSecurityGroupRule(id string) (SecurityGroupRule, error) { + return s.securityRuleRepository.Get(id) +} + +func (s *Service) DeleteSecurityGroupRule(id string) error { + return s.securityRuleRepository.Delete(id) +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index b4a189f..31b1197 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -12,6 +12,7 @@ type Service struct { subnetRepository SubnetRepository portRepository PortRepository securityGroupRepository SecurityGroupRepository + securityRuleRepository SecurityGroupRuleRepository idGen idgen.Generator } @@ -20,6 +21,7 @@ func NewServiceWithRepositories( subnetRepository SubnetRepository, portRepository PortRepository, securityGroupRepository SecurityGroupRepository, + securityRuleRepository SecurityGroupRuleRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -27,6 +29,7 @@ func NewServiceWithRepositories( subnetRepository: subnetRepository, portRepository: portRepository, securityGroupRepository: securityGroupRepository, + securityRuleRepository: securityRuleRepository, idGen: idGen, } } @@ -39,4 +42,5 @@ func (s *Service) Reset() { s.subnetRepository.Reset() s.portRepository.Reset() s.securityGroupRepository.Reset() + s.securityRuleRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index b89e9ec..6702ac2 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -60,12 +60,45 @@ func (s *ServiceSuite) TestCreateSecurityGroupUsesInjectedIDGenerator() { s.Assert().True(created.Stateful) } +func (s *ServiceSuite) TestCreateSecurityGroupRuleUsesInjectedIDGenerator() { + service := newService(idgen.Fixed("security-group-rule-id")) + securityGroup := service.CreateSecurityGroup(network.CreateSecurityGroup{ + Name: "web", + ProjectID: "demo", + }) + + created, err := service.CreateSecurityGroupRule( + network.CreateSecurityGroupRule{ + Direction: "ingress", + EtherType: "IPv4", + Protocol: "tcp", + PortRangeMin: 80, + PortRangeMax: 80, + RemoteIPPrefix: "0.0.0.0/0", + SecurityGroupID: securityGroup.ID, + }, + ) + s.Require().NoError(err) + + s.Assert().Equal("sgr-security-group-rule-id", created.ID) + s.Assert().Equal(securityGroup.ID, created.SecurityGroupID) + s.Assert().Equal("demo", created.ProjectID) +} + func (s *ServiceSuite) TestResetClearsNetworkResources() { service := newService(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.CreateSecurityGroup(network.CreateSecurityGroup{Name: "default"}) + securityGroup := service.CreateSecurityGroup(network.CreateSecurityGroup{ + Name: "default", + }) + _, err := service.CreateSecurityGroupRule(network.CreateSecurityGroupRule{ + Direction: "ingress", + EtherType: "IPv4", + SecurityGroupID: securityGroup.ID, + }) + s.Require().NoError(err) service.Reset() @@ -73,6 +106,7 @@ func (s *ServiceSuite) TestResetClearsNetworkResources() { s.Assert().Empty(service.ListSubnets()) s.Assert().Empty(service.ListPorts()) s.Assert().Empty(service.ListSecurityGroups()) + s.Assert().Empty(service.ListSecurityGroupRules()) } func newService(idGen idgen.Generator) *network.Service { @@ -81,6 +115,7 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 4fee492..1ab8c94 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -108,15 +108,31 @@ type CreateSecurityGroup struct { } type SecurityGroupRule struct { - ID string - Direction string - EtherType string - Protocol string - PortRangeMin int - PortRangeMax int - RemoteIPPrefix string - RemoteGroupID string - SecurityGroupID string - TenantID string - ProjectID string + ID string + Direction string + Description string + EtherType string + Protocol string + PortRangeMin int + PortRangeMax int + RemoteAddressGroupID string + RemoteIPPrefix string + RemoteGroupID string + SecurityGroupID string + TenantID string + ProjectID string +} + +type CreateSecurityGroupRule struct { + Direction string + Description string + EtherType string + Protocol string + PortRangeMin int + PortRangeMax int + RemoteAddressGroupID string + RemoteIPPrefix string + RemoteGroupID string + SecurityGroupID string + ProjectID string } diff --git a/internal/store/network/memory_security_group_rule_repository.go b/internal/store/network/memory_security_group_rule_repository.go new file mode 100644 index 0000000..7e1dddd --- /dev/null +++ b/internal/store/network/memory_security_group_rule_repository.go @@ -0,0 +1,85 @@ +package network + +import ( + "sync" + + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +) + +type MemorySecurityGroupRuleRepository struct { + mu sync.RWMutex + ids []string + rules map[string]appnetwork.SecurityGroupRule +} + +func NewMemorySecurityGroupRuleRepository() *MemorySecurityGroupRuleRepository { + return &MemorySecurityGroupRuleRepository{ + ids: []string{}, + rules: map[string]appnetwork.SecurityGroupRule{}, + } +} + +func (r *MemorySecurityGroupRuleRepository) Create( + rule appnetwork.SecurityGroupRule, +) appnetwork.SecurityGroupRule { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, rule.ID) + r.rules[rule.ID] = rule + + return rule +} + +func (r *MemorySecurityGroupRuleRepository) List() []appnetwork.SecurityGroupRule { + r.mu.RLock() + defer r.mu.RUnlock() + + rules := make([]appnetwork.SecurityGroupRule, 0, len(r.ids)) + for _, id := range r.ids { + rules = append(rules, r.rules[id]) + } + + return rules +} + +func (r *MemorySecurityGroupRuleRepository) Get( + id string, +) (appnetwork.SecurityGroupRule, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + rule, ok := r.rules[id] + if !ok { + return appnetwork.SecurityGroupRule{}, appnetwork.ErrSecurityGroupRuleNotFound + } + + return rule, nil +} + +func (r *MemorySecurityGroupRuleRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.rules[id]; !ok { + return appnetwork.ErrSecurityGroupRuleNotFound + } + + delete(r.rules, 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 *MemorySecurityGroupRuleRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.rules = map[string]appnetwork.SecurityGroupRule{} +} From a5cadd30248eff0b9137eca43ee0f516ba8beff4 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:28:01 +0900 Subject: [PATCH 3/5] feat(network): add router endpoints --- internal/api/network/l3_router.go | 60 +++++++ internal/api/network/l3_router_dto.go | 163 ++++++++++++++++++ internal/api/network/l3_router_test.go | 108 ++++++++++++ internal/api/network/router.go | 5 + internal/api/router.go | 1 + internal/app/network/l3_router.go | 46 +++++ internal/app/network/repository.go | 8 + internal/app/network/service.go | 4 + internal/app/network/service_test.go | 16 ++ internal/app/network/types.go | 42 +++++ .../store/network/memory_router_repository.go | 81 +++++++++ 11 files changed, 534 insertions(+) create mode 100644 internal/api/network/l3_router.go create mode 100644 internal/api/network/l3_router_dto.go create mode 100644 internal/api/network/l3_router_test.go create mode 100644 internal/app/network/l3_router.go create mode 100644 internal/store/network/memory_router_repository.go diff --git a/internal/api/network/l3_router.go b/internal/api/network/l3_router.go new file mode 100644 index 0000000..fb545d3 --- /dev/null +++ b/internal/api/network/l3_router.go @@ -0,0 +1,60 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listRouters(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, routerListResponse{ + Routers: toRouterDocuments(h.service.ListRouters()), + }) +} + +func (h Handler) createRouter(w http.ResponseWriter, r *http.Request) { + var request createRouterRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + router := h.service.CreateRouter(request.createRouter()) + respond.JSON(w, http.StatusCreated, routerResponse{ + Router: toRouterDocument(router), + }) +} + +func (h Handler) getRouter(w http.ResponseWriter, r *http.Request) { + router, err := h.service.GetRouter(chi.URLParam(r, "router_id")) + if errors.Is(err, appnetwork.ErrRouterNotFound) { + respond.Error(w, http.StatusNotFound, "router not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "router lookup failed") + return + } + + respond.JSON(w, http.StatusOK, routerResponse{ + Router: toRouterDocument(router), + }) +} + +func (h Handler) deleteRouter(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteRouter(chi.URLParam(r, "router_id")) + if errors.Is(err, appnetwork.ErrRouterNotFound) { + respond.Error(w, http.StatusNotFound, "router not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "router delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/l3_router_dto.go b/internal/api/network/l3_router_dto.go new file mode 100644 index 0000000..234365c --- /dev/null +++ b/internal/api/network/l3_router_dto.go @@ -0,0 +1,163 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type createRouterRequest struct { + Router createRouterDocument `json:"router"` +} + +type createRouterDocument struct { + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp *bool `json:"admin_state_up"` + Distributed *bool `json:"distributed"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` + GatewayInfo routerGatewayDocument `json:"external_gateway_info"` + AvailabilityZoneHints []string `json:"availability_zone_hints"` +} + +func (r createRouterRequest) createRouter() appnetwork.CreateRouter { + projectID := r.Router.ProjectID + if projectID == "" { + projectID = r.Router.TenantID + } + + return appnetwork.CreateRouter{ + Name: r.Router.Name, + Description: r.Router.Description, + AdminStateUp: r.Router.AdminStateUp, + Distributed: r.Router.Distributed, + ProjectID: projectID, + GatewayInfo: toAppRouterGateway(r.Router.GatewayInfo), + AvailabilityZoneHints: r.Router.AvailabilityZoneHints, + } +} + +type routerListResponse struct { + Routers []routerDocument `json:"routers"` +} + +type routerResponse struct { + Router routerDocument `json:"router"` +} + +type routerDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + AdminStateUp bool `json:"admin_state_up"` + Distributed bool `json:"distributed"` + Status string `json:"status"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + GatewayInfo routerGatewayDocument `json:"external_gateway_info"` + Routes []routeDocument `json:"routes"` + AvailabilityZoneHints []string `json:"availability_zone_hints"` + Tags []string `json:"tags"` +} + +type routerGatewayDocument struct { + NetworkID string `json:"network_id,omitempty"` + EnableSNAT *bool `json:"enable_snat,omitempty"` + ExternalFixedIPs []externalFixedIPDocument `json:"external_fixed_ips,omitempty"` + QoSPolicyID string `json:"qos_policy_id,omitempty"` +} + +type externalFixedIPDocument struct { + IPAddress string `json:"ip_address,omitempty"` + SubnetID string `json:"subnet_id,omitempty"` +} + +type routeDocument struct { + NextHop string `json:"nexthop"` + DestinationCIDR string `json:"destination"` +} + +func toRouterDocuments(routers []appnetwork.Router) []routerDocument { + documents := make([]routerDocument, 0, len(routers)) + for _, router := range routers { + documents = append(documents, toRouterDocument(router)) + } + + return documents +} + +func toRouterDocument(router appnetwork.Router) routerDocument { + return routerDocument{ + ID: router.ID, + Name: router.Name, + Description: router.Description, + AdminStateUp: router.AdminStateUp, + Distributed: router.Distributed, + Status: router.Status, + TenantID: router.TenantID, + ProjectID: router.ProjectID, + GatewayInfo: toRouterGatewayDocument(router.GatewayInfo), + Routes: toRouteDocuments(router.Routes), + AvailabilityZoneHints: router.AvailabilityZoneHints, + Tags: router.Tags, + } +} + +func toAppRouterGateway( + gateway routerGatewayDocument, +) appnetwork.RouterGatewayInfo { + return appnetwork.RouterGatewayInfo{ + NetworkID: gateway.NetworkID, + EnableSNAT: gateway.EnableSNAT, + ExternalFixedIPs: toAppExternalFixedIPs(gateway.ExternalFixedIPs), + QoSPolicyID: gateway.QoSPolicyID, + } +} + +func toRouterGatewayDocument( + gateway appnetwork.RouterGatewayInfo, +) routerGatewayDocument { + return routerGatewayDocument{ + NetworkID: gateway.NetworkID, + EnableSNAT: gateway.EnableSNAT, + ExternalFixedIPs: toExternalFixedIPDocuments(gateway.ExternalFixedIPs), + QoSPolicyID: gateway.QoSPolicyID, + } +} + +func toAppExternalFixedIPs( + fixedIPs []externalFixedIPDocument, +) []appnetwork.ExternalFixedIP { + values := make([]appnetwork.ExternalFixedIP, 0, len(fixedIPs)) + for _, fixedIP := range fixedIPs { + values = append(values, appnetwork.ExternalFixedIP{ + IPAddress: fixedIP.IPAddress, + SubnetID: fixedIP.SubnetID, + }) + } + + return values +} + +func toExternalFixedIPDocuments( + fixedIPs []appnetwork.ExternalFixedIP, +) []externalFixedIPDocument { + documents := make([]externalFixedIPDocument, 0, len(fixedIPs)) + for _, fixedIP := range fixedIPs { + documents = append(documents, externalFixedIPDocument{ + IPAddress: fixedIP.IPAddress, + SubnetID: fixedIP.SubnetID, + }) + } + + return documents +} + +func toRouteDocuments(routes []appnetwork.Route) []routeDocument { + documents := make([]routeDocument, 0, len(routes)) + for _, route := range routes { + documents = append(documents, routeDocument{ + NextHop: route.NextHop, + DestinationCIDR: route.DestinationCIDR, + }) + } + + return documents +} diff --git a/internal/api/network/l3_router_test.go b/internal/api/network/l3_router_test.go new file mode 100644 index 0000000..90c4eda --- /dev/null +++ b/internal/api/network/l3_router_test.go @@ -0,0 +1,108 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routers" + "github.com/stretchr/testify/suite" +) + +type RouterSuite struct { + suite.Suite + server *httptest.Server +} + +func TestRouterSuite(t *testing.T) { + suite.Run(t, new(RouterSuite)) +} + +func (s *RouterSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *RouterSuite) TearDownTest() { + s.server.Close() +} + +func (s *RouterSuite) TestListRouters() { + list := s.listRouters() + + s.Assert().Empty(list) +} + +func (s *RouterSuite) TestCreateRouterThenListRouters() { + created := s.createRouter("edge") + + list := s.listRouters() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("edge", created.Name) + s.Assert().Equal("ACTIVE", created.Status) + s.Assert().True(created.AdminStateUp) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) + s.Assert().Equal("edge", list[0].Name) +} + +func (s *RouterSuite) TestGetRouter() { + created := s.createRouter("edge") + + found, err := routers.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("edge", found.Name) +} + +func (s *RouterSuite) TestDeleteRouter() { + created := s.createRouter("edge") + + err := routers.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listRouters() + + s.Assert().Empty(list) +} + +func (s *RouterSuite) listRouters() []routers.Router { + pages, err := routers.List( + testhelper.ServiceClient(s.server.URL), + routers.ListOpts{}, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := routers.ExtractRouters(pages) + s.Require().NoError(err) + + return list +} + +func (s *RouterSuite) createRouter(name string) *routers.Router { + created, err := routers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + routers.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 2396c4a..9bb2bb0 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -35,6 +35,7 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), idgen.Random(), ), ) @@ -73,6 +74,10 @@ func (h Handler) Router() http.Handler { router.Post("/security-group-rules", h.createSecurityGroupRule) router.Get("/security-group-rules/{security_group_rule_id}", h.getSecurityGroupRule) router.Delete("/security-group-rules/{security_group_rule_id}", h.deleteSecurityGroupRule) + router.Get("/routers", h.listRouters) + router.Post("/routers", h.createRouter) + router.Get("/routers/{router_id}", h.getRouter) + router.Delete("/routers/{router_id}", h.deleteRouter) return router } diff --git a/internal/api/router.go b/internal/api/router.go index df81d93..94b653d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -63,6 +63,7 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), idgen.Random(), ) volumeService := appvolume.NewServiceWithRuntime( diff --git a/internal/app/network/l3_router.go b/internal/app/network/l3_router.go new file mode 100644 index 0000000..426e16e --- /dev/null +++ b/internal/app/network/l3_router.go @@ -0,0 +1,46 @@ +package network + +import "errors" + +var ErrRouterNotFound = errors.New("router not found") + +func (s *Service) CreateRouter(input CreateRouter) Router { + adminStateUp := true + if input.AdminStateUp != nil { + adminStateUp = *input.AdminStateUp + } + + distributed := false + if input.Distributed != nil { + distributed = *input.Distributed + } + + router := Router{ + ID: "router-" + s.idGen.Hex(16), + Name: input.Name, + Description: input.Description, + AdminStateUp: adminStateUp, + Distributed: distributed, + Status: "ACTIVE", + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + GatewayInfo: input.GatewayInfo, + Routes: []Route{}, + AvailabilityZoneHints: input.AvailabilityZoneHints, + Tags: []string{}, + } + + return s.routerRepository.Create(router) +} + +func (s *Service) ListRouters() []Router { + return s.routerRepository.List() +} + +func (s *Service) GetRouter(id string) (Router, error) { + return s.routerRepository.Get(id) +} + +func (s *Service) DeleteRouter(id string) error { + return s.routerRepository.Delete(id) +} diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index c07fb40..d65ec5f 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -40,3 +40,11 @@ type SecurityGroupRuleRepository interface { Delete(id string) error Reset() } + +type RouterRepository interface { + Create(router Router) Router + List() []Router + Get(id string) (Router, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 31b1197..6d04aef 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -13,6 +13,7 @@ type Service struct { portRepository PortRepository securityGroupRepository SecurityGroupRepository securityRuleRepository SecurityGroupRuleRepository + routerRepository RouterRepository idGen idgen.Generator } @@ -22,6 +23,7 @@ func NewServiceWithRepositories( portRepository PortRepository, securityGroupRepository SecurityGroupRepository, securityRuleRepository SecurityGroupRuleRepository, + routerRepository RouterRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -30,6 +32,7 @@ func NewServiceWithRepositories( portRepository: portRepository, securityGroupRepository: securityGroupRepository, securityRuleRepository: securityRuleRepository, + routerRepository: routerRepository, idGen: idGen, } } @@ -43,4 +46,5 @@ func (s *Service) Reset() { s.portRepository.Reset() s.securityGroupRepository.Reset() s.securityRuleRepository.Reset() + s.routerRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index 6702ac2..77c6ad9 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -85,6 +85,19 @@ func (s *ServiceSuite) TestCreateSecurityGroupRuleUsesInjectedIDGenerator() { s.Assert().Equal("demo", created.ProjectID) } +func (s *ServiceSuite) TestCreateRouterUsesInjectedIDGenerator() { + service := newService(idgen.Fixed("router-id")) + + created := service.CreateRouter(network.CreateRouter{ + Name: "edge", + }) + + s.Assert().Equal("router-router-id", created.ID) + s.Assert().Equal("edge", created.Name) + s.Assert().Equal("ACTIVE", created.Status) + s.Assert().True(created.AdminStateUp) +} + func (s *ServiceSuite) TestResetClearsNetworkResources() { service := newService(idgen.Fixed("network-id")) created := service.Create(network.CreateNetwork{Name: "private"}) @@ -99,6 +112,7 @@ func (s *ServiceSuite) TestResetClearsNetworkResources() { SecurityGroupID: securityGroup.ID, }) s.Require().NoError(err) + service.CreateRouter(network.CreateRouter{Name: "edge"}) service.Reset() @@ -107,6 +121,7 @@ func (s *ServiceSuite) TestResetClearsNetworkResources() { s.Assert().Empty(service.ListPorts()) s.Assert().Empty(service.ListSecurityGroups()) s.Assert().Empty(service.ListSecurityGroupRules()) + s.Assert().Empty(service.ListRouters()) } func newService(idGen idgen.Generator) *network.Service { @@ -116,6 +131,7 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemoryPortRepository(), storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 1ab8c94..57024f2 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -136,3 +136,45 @@ type CreateSecurityGroupRule struct { SecurityGroupID string ProjectID string } + +type Router struct { + ID string + Name string + Description string + AdminStateUp bool + Distributed bool + Status string + TenantID string + ProjectID string + GatewayInfo RouterGatewayInfo + Routes []Route + AvailabilityZoneHints []string + Tags []string +} + +type CreateRouter struct { + Name string + Description string + AdminStateUp *bool + Distributed *bool + ProjectID string + GatewayInfo RouterGatewayInfo + AvailabilityZoneHints []string +} + +type RouterGatewayInfo struct { + NetworkID string + EnableSNAT *bool + ExternalFixedIPs []ExternalFixedIP + QoSPolicyID string +} + +type ExternalFixedIP struct { + IPAddress string + SubnetID string +} + +type Route struct { + NextHop string + DestinationCIDR string +} diff --git a/internal/store/network/memory_router_repository.go b/internal/store/network/memory_router_repository.go new file mode 100644 index 0000000..e352974 --- /dev/null +++ b/internal/store/network/memory_router_repository.go @@ -0,0 +1,81 @@ +package network + +import ( + "sync" + + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +) + +type MemoryRouterRepository struct { + mu sync.RWMutex + ids []string + routers map[string]appnetwork.Router +} + +func NewMemoryRouterRepository() *MemoryRouterRepository { + return &MemoryRouterRepository{ + ids: []string{}, + routers: map[string]appnetwork.Router{}, + } +} + +func (r *MemoryRouterRepository) Create(router appnetwork.Router) appnetwork.Router { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, router.ID) + r.routers[router.ID] = router + + return router +} + +func (r *MemoryRouterRepository) List() []appnetwork.Router { + r.mu.RLock() + defer r.mu.RUnlock() + + routers := make([]appnetwork.Router, 0, len(r.ids)) + for _, id := range r.ids { + routers = append(routers, r.routers[id]) + } + + return routers +} + +func (r *MemoryRouterRepository) Get(id string) (appnetwork.Router, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + router, ok := r.routers[id] + if !ok { + return appnetwork.Router{}, appnetwork.ErrRouterNotFound + } + + return router, nil +} + +func (r *MemoryRouterRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.routers[id]; !ok { + return appnetwork.ErrRouterNotFound + } + + delete(r.routers, 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 *MemoryRouterRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.routers = map[string]appnetwork.Router{} +} From 01607ae8beccd3408dfc5bba8501bc87ee15779a Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:30:17 +0900 Subject: [PATCH 4/5] feat(network): add floating IP endpoints --- internal/api/network/floating_ip.go | 60 ++++++++++ internal/api/network/floating_ip_dto.go | 84 +++++++++++++ internal/api/network/floating_ip_test.go | 111 ++++++++++++++++++ internal/api/network/router.go | 5 + internal/api/router.go | 1 + internal/app/network/floating_ip.go | 44 +++++++ internal/app/network/repository.go | 8 ++ internal/app/network/service.go | 4 + internal/app/network/service_test.go | 19 +++ internal/app/network/types.go | 24 ++++ .../network/memory_floating_ip_repository.go | 85 ++++++++++++++ 11 files changed, 445 insertions(+) create mode 100644 internal/api/network/floating_ip.go create mode 100644 internal/api/network/floating_ip_dto.go create mode 100644 internal/api/network/floating_ip_test.go create mode 100644 internal/app/network/floating_ip.go create mode 100644 internal/store/network/memory_floating_ip_repository.go diff --git a/internal/api/network/floating_ip.go b/internal/api/network/floating_ip.go new file mode 100644 index 0000000..3f63629 --- /dev/null +++ b/internal/api/network/floating_ip.go @@ -0,0 +1,60 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listFloatingIPs(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, floatingIPListResponse{ + FloatingIPs: toFloatingIPDocuments(h.service.ListFloatingIPs()), + }) +} + +func (h Handler) createFloatingIP(w http.ResponseWriter, r *http.Request) { + var request createFloatingIPRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + floatingIP := h.service.CreateFloatingIP(request.createFloatingIP()) + respond.JSON(w, http.StatusCreated, floatingIPResponse{ + FloatingIP: toFloatingIPDocument(floatingIP), + }) +} + +func (h Handler) getFloatingIP(w http.ResponseWriter, r *http.Request) { + floatingIP, err := h.service.GetFloatingIP(chi.URLParam(r, "floating_ip_id")) + if errors.Is(err, appnetwork.ErrFloatingIPNotFound) { + respond.Error(w, http.StatusNotFound, "floating IP not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "floating IP lookup failed") + return + } + + respond.JSON(w, http.StatusOK, floatingIPResponse{ + FloatingIP: toFloatingIPDocument(floatingIP), + }) +} + +func (h Handler) deleteFloatingIP(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteFloatingIP(chi.URLParam(r, "floating_ip_id")) + if errors.Is(err, appnetwork.ErrFloatingIPNotFound) { + respond.Error(w, http.StatusNotFound, "floating IP not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "floating IP delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/network/floating_ip_dto.go b/internal/api/network/floating_ip_dto.go new file mode 100644 index 0000000..718e0f8 --- /dev/null +++ b/internal/api/network/floating_ip_dto.go @@ -0,0 +1,84 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type createFloatingIPRequest struct { + FloatingIP createFloatingIPDocument `json:"floatingip"` +} + +type createFloatingIPDocument struct { + Description string `json:"description"` + FloatingNetworkID string `json:"floating_network_id"` + FloatingIP string `json:"floating_ip_address"` + PortID string `json:"port_id"` + FixedIP string `json:"fixed_ip_address"` + SubnetID string `json:"subnet_id"` + ProjectID string `json:"project_id"` + TenantID string `json:"tenant_id"` +} + +func (r createFloatingIPRequest) createFloatingIP() appnetwork.CreateFloatingIP { + projectID := r.FloatingIP.ProjectID + if projectID == "" { + projectID = r.FloatingIP.TenantID + } + + return appnetwork.CreateFloatingIP{ + Description: r.FloatingIP.Description, + FloatingNetworkID: r.FloatingIP.FloatingNetworkID, + FloatingIP: r.FloatingIP.FloatingIP, + PortID: r.FloatingIP.PortID, + FixedIP: r.FloatingIP.FixedIP, + SubnetID: r.FloatingIP.SubnetID, + ProjectID: projectID, + } +} + +type floatingIPListResponse struct { + FloatingIPs []floatingIPDocument `json:"floatingips"` +} + +type floatingIPResponse struct { + FloatingIP floatingIPDocument `json:"floatingip"` +} + +type floatingIPDocument struct { + ID string `json:"id"` + Description string `json:"description"` + FloatingNetworkID string `json:"floating_network_id"` + FloatingIP string `json:"floating_ip_address"` + PortID string `json:"port_id"` + FixedIP string `json:"fixed_ip_address"` + TenantID string `json:"tenant_id"` + ProjectID string `json:"project_id"` + Status string `json:"status"` + RouterID string `json:"router_id"` + Tags []string `json:"tags"` +} + +func toFloatingIPDocuments( + floatingIPs []appnetwork.FloatingIP, +) []floatingIPDocument { + documents := make([]floatingIPDocument, 0, len(floatingIPs)) + for _, floatingIP := range floatingIPs { + documents = append(documents, toFloatingIPDocument(floatingIP)) + } + + return documents +} + +func toFloatingIPDocument(floatingIP appnetwork.FloatingIP) floatingIPDocument { + return floatingIPDocument{ + ID: floatingIP.ID, + Description: floatingIP.Description, + FloatingNetworkID: floatingIP.FloatingNetworkID, + FloatingIP: floatingIP.FloatingIP, + PortID: floatingIP.PortID, + FixedIP: floatingIP.FixedIP, + TenantID: floatingIP.TenantID, + ProjectID: floatingIP.ProjectID, + Status: floatingIP.Status, + RouterID: floatingIP.RouterID, + Tags: floatingIP.Tags, + } +} diff --git a/internal/api/network/floating_ip_test.go b/internal/api/network/floating_ip_test.go new file mode 100644 index 0000000..677f2af --- /dev/null +++ b/internal/api/network/floating_ip_test.go @@ -0,0 +1,111 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/floatingips" + "github.com/stretchr/testify/suite" +) + +type FloatingIPSuite struct { + suite.Suite + server *httptest.Server +} + +func TestFloatingIPSuite(t *testing.T) { + suite.Run(t, new(FloatingIPSuite)) +} + +func (s *FloatingIPSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *FloatingIPSuite) TearDownTest() { + s.server.Close() +} + +func (s *FloatingIPSuite) TestListFloatingIPs() { + list := s.listFloatingIPs() + + s.Assert().Empty(list) +} + +func (s *FloatingIPSuite) TestCreateFloatingIPThenListFloatingIPs() { + created := s.createFloatingIP("public") + + list := s.listFloatingIPs() + + s.Assert().NotEmpty(created.ID) + s.Assert().Equal("public", created.FloatingNetworkID) + s.Assert().Equal("203.0.113.20", created.FloatingIP) + s.Assert().Equal("DOWN", created.Status) + s.Require().Len(list, 1) + s.Assert().Equal(created.ID, list[0].ID) +} + +func (s *FloatingIPSuite) TestGetFloatingIP() { + created := s.createFloatingIP("public") + + found, err := floatingips.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.ID, found.ID) + s.Assert().Equal("public", found.FloatingNetworkID) +} + +func (s *FloatingIPSuite) TestDeleteFloatingIP() { + created := s.createFloatingIP("public") + + err := floatingips.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listFloatingIPs() + + s.Assert().Empty(list) +} + +func (s *FloatingIPSuite) listFloatingIPs() []floatingips.FloatingIP { + pages, err := floatingips.List( + testhelper.ServiceClient(s.server.URL), + floatingips.ListOpts{}, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := floatingips.ExtractFloatingIPs(pages) + s.Require().NoError(err) + + return list +} + +func (s *FloatingIPSuite) createFloatingIP( + floatingNetworkID string, +) *floatingips.FloatingIP { + created, err := floatingips.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + floatingips.CreateOpts{ + Description: "floating IP for tests", + FloatingNetworkID: floatingNetworkID, + FloatingIP: "203.0.113.20", + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 9bb2bb0..6b387d6 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -36,6 +36,7 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), idgen.Random(), ), ) @@ -78,6 +79,10 @@ func (h Handler) Router() http.Handler { router.Post("/routers", h.createRouter) router.Get("/routers/{router_id}", h.getRouter) router.Delete("/routers/{router_id}", h.deleteRouter) + router.Get("/floatingips", h.listFloatingIPs) + router.Post("/floatingips", h.createFloatingIP) + router.Get("/floatingips/{floating_ip_id}", h.getFloatingIP) + router.Delete("/floatingips/{floating_ip_id}", h.deleteFloatingIP) return router } diff --git a/internal/api/router.go b/internal/api/router.go index 94b653d..3249abc 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -64,6 +64,7 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), idgen.Random(), ) volumeService := appvolume.NewServiceWithRuntime( diff --git a/internal/app/network/floating_ip.go b/internal/app/network/floating_ip.go new file mode 100644 index 0000000..1357f94 --- /dev/null +++ b/internal/app/network/floating_ip.go @@ -0,0 +1,44 @@ +package network + +import "errors" + +var ErrFloatingIPNotFound = errors.New("floating IP not found") + +func (s *Service) CreateFloatingIP(input CreateFloatingIP) FloatingIP { + status := "DOWN" + if input.PortID != "" { + status = "ACTIVE" + } + + floatingIP := input.FloatingIP + if floatingIP == "" { + floatingIP = "203.0.113.10" + } + + value := FloatingIP{ + ID: "fip-" + s.idGen.Hex(16), + Description: input.Description, + FloatingNetworkID: input.FloatingNetworkID, + FloatingIP: floatingIP, + PortID: input.PortID, + FixedIP: input.FixedIP, + TenantID: input.ProjectID, + ProjectID: input.ProjectID, + Status: status, + Tags: []string{}, + } + + return s.floatingIPRepository.Create(value) +} + +func (s *Service) ListFloatingIPs() []FloatingIP { + return s.floatingIPRepository.List() +} + +func (s *Service) GetFloatingIP(id string) (FloatingIP, error) { + return s.floatingIPRepository.Get(id) +} + +func (s *Service) DeleteFloatingIP(id string) error { + return s.floatingIPRepository.Delete(id) +} diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index d65ec5f..6a97f1e 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -48,3 +48,11 @@ type RouterRepository interface { Delete(id string) error Reset() } + +type FloatingIPRepository interface { + Create(floatingIP FloatingIP) FloatingIP + List() []FloatingIP + Get(id string) (FloatingIP, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index 6d04aef..cdd3ffc 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -14,6 +14,7 @@ type Service struct { securityGroupRepository SecurityGroupRepository securityRuleRepository SecurityGroupRuleRepository routerRepository RouterRepository + floatingIPRepository FloatingIPRepository idGen idgen.Generator } @@ -24,6 +25,7 @@ func NewServiceWithRepositories( securityGroupRepository SecurityGroupRepository, securityRuleRepository SecurityGroupRuleRepository, routerRepository RouterRepository, + floatingIPRepository FloatingIPRepository, idGen idgen.Generator, ) *Service { return &Service{ @@ -33,6 +35,7 @@ func NewServiceWithRepositories( securityGroupRepository: securityGroupRepository, securityRuleRepository: securityRuleRepository, routerRepository: routerRepository, + floatingIPRepository: floatingIPRepository, idGen: idGen, } } @@ -47,4 +50,5 @@ func (s *Service) Reset() { s.securityGroupRepository.Reset() s.securityRuleRepository.Reset() s.routerRepository.Reset() + s.floatingIPRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index 77c6ad9..6ef7145 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -98,6 +98,20 @@ func (s *ServiceSuite) TestCreateRouterUsesInjectedIDGenerator() { s.Assert().True(created.AdminStateUp) } +func (s *ServiceSuite) TestCreateFloatingIPUsesInjectedIDGenerator() { + service := newService(idgen.Fixed("floating-ip-id")) + + created := service.CreateFloatingIP(network.CreateFloatingIP{ + FloatingNetworkID: "public", + ProjectID: "demo", + }) + + s.Assert().Equal("fip-floating-ip-id", created.ID) + s.Assert().Equal("public", created.FloatingNetworkID) + s.Assert().Equal("203.0.113.10", created.FloatingIP) + s.Assert().Equal("DOWN", created.Status) +} + func (s *ServiceSuite) TestResetClearsNetworkResources() { service := newService(idgen.Fixed("network-id")) created := service.Create(network.CreateNetwork{Name: "private"}) @@ -113,6 +127,9 @@ func (s *ServiceSuite) TestResetClearsNetworkResources() { }) s.Require().NoError(err) service.CreateRouter(network.CreateRouter{Name: "edge"}) + service.CreateFloatingIP(network.CreateFloatingIP{ + FloatingNetworkID: created.ID, + }) service.Reset() @@ -122,6 +139,7 @@ func (s *ServiceSuite) TestResetClearsNetworkResources() { s.Assert().Empty(service.ListSecurityGroups()) s.Assert().Empty(service.ListSecurityGroupRules()) s.Assert().Empty(service.ListRouters()) + s.Assert().Empty(service.ListFloatingIPs()) } func newService(idGen idgen.Generator) *network.Service { @@ -132,6 +150,7 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemorySecurityGroupRepository(), storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 57024f2..b69fc61 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -178,3 +178,27 @@ type Route struct { NextHop string DestinationCIDR string } + +type FloatingIP struct { + ID string + Description string + FloatingNetworkID string + FloatingIP string + PortID string + FixedIP string + TenantID string + ProjectID string + Status string + RouterID string + Tags []string +} + +type CreateFloatingIP struct { + Description string + FloatingNetworkID string + FloatingIP string + PortID string + FixedIP string + SubnetID string + ProjectID string +} diff --git a/internal/store/network/memory_floating_ip_repository.go b/internal/store/network/memory_floating_ip_repository.go new file mode 100644 index 0000000..b330183 --- /dev/null +++ b/internal/store/network/memory_floating_ip_repository.go @@ -0,0 +1,85 @@ +package network + +import ( + "sync" + + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +) + +type MemoryFloatingIPRepository struct { + mu sync.RWMutex + ids []string + floatingIPs map[string]appnetwork.FloatingIP +} + +func NewMemoryFloatingIPRepository() *MemoryFloatingIPRepository { + return &MemoryFloatingIPRepository{ + ids: []string{}, + floatingIPs: map[string]appnetwork.FloatingIP{}, + } +} + +func (r *MemoryFloatingIPRepository) Create( + floatingIP appnetwork.FloatingIP, +) appnetwork.FloatingIP { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, floatingIP.ID) + r.floatingIPs[floatingIP.ID] = floatingIP + + return floatingIP +} + +func (r *MemoryFloatingIPRepository) List() []appnetwork.FloatingIP { + r.mu.RLock() + defer r.mu.RUnlock() + + floatingIPs := make([]appnetwork.FloatingIP, 0, len(r.ids)) + for _, id := range r.ids { + floatingIPs = append(floatingIPs, r.floatingIPs[id]) + } + + return floatingIPs +} + +func (r *MemoryFloatingIPRepository) Get( + id string, +) (appnetwork.FloatingIP, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + floatingIP, ok := r.floatingIPs[id] + if !ok { + return appnetwork.FloatingIP{}, appnetwork.ErrFloatingIPNotFound + } + + return floatingIP, nil +} + +func (r *MemoryFloatingIPRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.floatingIPs[id]; !ok { + return appnetwork.ErrFloatingIPNotFound + } + + delete(r.floatingIPs, 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 *MemoryFloatingIPRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.floatingIPs = map[string]appnetwork.FloatingIP{} +} From ac36a97ff693e7ab8cb1f739c0ffbeabcf9a39c2 Mon Sep 17 00:00:00 2001 From: JaeSang Yoo Date: Fri, 3 Jul 2026 03:32:34 +0900 Subject: [PATCH 5/5] feat(network): add router interface endpoints --- internal/api/network/router.go | 3 + internal/api/network/router_interface.go | 61 +++++++++ internal/api/network/router_interface_dto.go | 33 +++++ internal/api/network/router_interface_test.go | 116 ++++++++++++++++++ internal/api/router.go | 1 + internal/app/network/repository.go | 7 ++ internal/app/network/router_interface.go | 51 ++++++++ internal/app/network/service.go | 38 +++--- internal/app/network/service_test.go | 26 ++++ internal/app/network/types.go | 13 ++ .../memory_router_interface_repository.go | 82 +++++++++++++ 11 files changed, 414 insertions(+), 17 deletions(-) create mode 100644 internal/api/network/router_interface.go create mode 100644 internal/api/network/router_interface_dto.go create mode 100644 internal/api/network/router_interface_test.go create mode 100644 internal/app/network/router_interface.go create mode 100644 internal/store/network/memory_router_interface_repository.go diff --git a/internal/api/network/router.go b/internal/api/network/router.go index 6b387d6..d86a44c 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -37,6 +37,7 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), idgen.Random(), ), ) @@ -79,6 +80,8 @@ func (h Handler) Router() http.Handler { router.Post("/routers", h.createRouter) router.Get("/routers/{router_id}", h.getRouter) router.Delete("/routers/{router_id}", h.deleteRouter) + router.Put("/routers/{router_id}/add_router_interface", h.addRouterInterface) + router.Put("/routers/{router_id}/remove_router_interface", h.removeRouterInterface) router.Get("/floatingips", h.listFloatingIPs) router.Post("/floatingips", h.createFloatingIP) router.Get("/floatingips/{floating_ip_id}", h.getFloatingIP) diff --git a/internal/api/network/router_interface.go b/internal/api/network/router_interface.go new file mode 100644 index 0000000..34e03d7 --- /dev/null +++ b/internal/api/network/router_interface.go @@ -0,0 +1,61 @@ +package network + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + "github.com/go-chi/chi/v5" +) + +func (h Handler) addRouterInterface(w http.ResponseWriter, r *http.Request) { + var request routerInterfaceRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + routerInterface, err := h.service.AddRouterInterface( + chi.URLParam(r, "router_id"), + request.routerInterfaceRequest(), + ) + if errors.Is(err, appnetwork.ErrRouterNotFound) { + respond.Error(w, http.StatusNotFound, "router not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "router interface add failed") + return + } + + respond.JSON(w, http.StatusOK, toRouterInterfaceDocument(routerInterface)) +} + +func (h Handler) removeRouterInterface(w http.ResponseWriter, r *http.Request) { + var request routerInterfaceRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + routerInterface, err := h.service.RemoveRouterInterface( + chi.URLParam(r, "router_id"), + request.routerInterfaceRequest(), + ) + if errors.Is(err, appnetwork.ErrRouterNotFound) { + respond.Error(w, http.StatusNotFound, "router not found") + return + } + if errors.Is(err, appnetwork.ErrRouterInterfaceNotFound) { + respond.Error(w, http.StatusNotFound, "router interface not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "router interface remove failed") + return + } + + respond.JSON(w, http.StatusOK, toRouterInterfaceDocument(routerInterface)) +} diff --git a/internal/api/network/router_interface_dto.go b/internal/api/network/router_interface_dto.go new file mode 100644 index 0000000..eb6b2a3 --- /dev/null +++ b/internal/api/network/router_interface_dto.go @@ -0,0 +1,33 @@ +package network + +import appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" + +type routerInterfaceRequest struct { + SubnetID string `json:"subnet_id"` + PortID string `json:"port_id"` +} + +func (r routerInterfaceRequest) routerInterfaceRequest() appnetwork.RouterInterfaceRequest { + return appnetwork.RouterInterfaceRequest{ + SubnetID: r.SubnetID, + PortID: r.PortID, + } +} + +type routerInterfaceDocument struct { + ID string `json:"id"` + SubnetID string `json:"subnet_id"` + PortID string `json:"port_id"` + TenantID string `json:"tenant_id"` +} + +func toRouterInterfaceDocument( + routerInterface appnetwork.RouterInterface, +) routerInterfaceDocument { + return routerInterfaceDocument{ + ID: routerInterface.ID, + SubnetID: routerInterface.SubnetID, + PortID: routerInterface.PortID, + TenantID: routerInterface.TenantID, + } +} diff --git a/internal/api/network/router_interface_test.go b/internal/api/network/router_interface_test.go new file mode 100644 index 0000000..269004e --- /dev/null +++ b/internal/api/network/router_interface_test.go @@ -0,0 +1,116 @@ +package network_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/network" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/extensions/layer3/routers" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/networks" + "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/subnets" + "github.com/stretchr/testify/suite" +) + +type RouterInterfaceSuite struct { + suite.Suite + server *httptest.Server +} + +func TestRouterInterfaceSuite(t *testing.T) { + suite.Run(t, new(RouterInterfaceSuite)) +} + +func (s *RouterInterfaceSuite) SetupTest() { + s.server = httptest.NewServer( + network.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *RouterInterfaceSuite) TearDownTest() { + s.server.Close() +} + +func (s *RouterInterfaceSuite) TestAddAndRemoveRouterInterface() { + network := s.createNetwork("private") + subnet := s.createSubnet(network.ID, "private-subnet") + router := s.createRouter("edge") + + added, err := routers.AddInterface( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + router.ID, + routers.AddInterfaceOpts{SubnetID: subnet.ID}, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(added) + + removed, err := routers.RemoveInterface( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + router.ID, + routers.RemoveInterfaceOpts{SubnetID: subnet.ID}, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(removed) + + s.Assert().Equal(subnet.ID, added.SubnetID) + s.Assert().NotEmpty(added.PortID) + s.Assert().NotEmpty(added.ID) + s.Assert().Equal(added.ID, removed.ID) + s.Assert().Equal(added.PortID, removed.PortID) + s.Assert().Equal("demo", removed.TenantID) +} + +func (s *RouterInterfaceSuite) createNetwork(name string) *networks.Network { + created, err := networks.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + networks.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *RouterInterfaceSuite) createSubnet( + networkID string, + name string, +) *subnets.Subnet { + created, err := subnets.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + subnets.CreateOpts{ + NetworkID: networkID, + Name: name, + CIDR: "192.168.10.0/24", + IPVersion: gophercloud.IPv4, + ProjectID: "demo", + EnableDHCP: boolPtr(true), + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} + +func (s *RouterInterfaceSuite) createRouter(name string) *routers.Router { + created, err := routers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL), + routers.CreateOpts{ + Name: name, + ProjectID: "demo", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/router.go b/internal/api/router.go index 3249abc..136bfa7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -65,6 +65,7 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), idgen.Random(), ) volumeService := appvolume.NewServiceWithRuntime( diff --git a/internal/app/network/repository.go b/internal/app/network/repository.go index 6a97f1e..b31b464 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -56,3 +56,10 @@ type FloatingIPRepository interface { Delete(id string) error Reset() } + +type RouterInterfaceRepository interface { + Create(routerInterface RouterInterface) RouterInterface + Find(routerID string, request RouterInterfaceRequest) (RouterInterface, error) + Delete(id string) error + Reset() +} diff --git a/internal/app/network/router_interface.go b/internal/app/network/router_interface.go new file mode 100644 index 0000000..c5225de --- /dev/null +++ b/internal/app/network/router_interface.go @@ -0,0 +1,51 @@ +package network + +import "errors" + +var ErrRouterInterfaceNotFound = errors.New("router interface not found") + +func (s *Service) AddRouterInterface( + routerID string, + request RouterInterfaceRequest, +) (RouterInterface, error) { + router, err := s.routerRepository.Get(routerID) + if err != nil { + return RouterInterface{}, err + } + + portID := request.PortID + subnetID := request.SubnetID + if portID == "" { + portID = "port-" + s.idGen.Hex(16) + } + + routerInterface := RouterInterface{ + ID: "ri-" + s.idGen.Hex(16), + RouterID: routerID, + SubnetID: subnetID, + PortID: portID, + TenantID: router.ProjectID, + } + + return s.routerInterfaceRepository.Create(routerInterface), nil +} + +func (s *Service) RemoveRouterInterface( + routerID string, + request RouterInterfaceRequest, +) (RouterInterface, error) { + if _, err := s.routerRepository.Get(routerID); err != nil { + return RouterInterface{}, err + } + + routerInterface, err := s.routerInterfaceRepository.Find(routerID, request) + if err != nil { + return RouterInterface{}, err + } + + if err := s.routerInterfaceRepository.Delete(routerInterface.ID); err != nil { + return RouterInterface{}, err + } + + return routerInterface, nil +} diff --git a/internal/app/network/service.go b/internal/app/network/service.go index cdd3ffc..2a47e58 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -7,15 +7,16 @@ import ( ) type Service struct { - mu sync.RWMutex - networkRepository NetworkRepository - subnetRepository SubnetRepository - portRepository PortRepository - securityGroupRepository SecurityGroupRepository - securityRuleRepository SecurityGroupRuleRepository - routerRepository RouterRepository - floatingIPRepository FloatingIPRepository - idGen idgen.Generator + mu sync.RWMutex + networkRepository NetworkRepository + subnetRepository SubnetRepository + portRepository PortRepository + securityGroupRepository SecurityGroupRepository + securityRuleRepository SecurityGroupRuleRepository + routerRepository RouterRepository + floatingIPRepository FloatingIPRepository + routerInterfaceRepository RouterInterfaceRepository + idGen idgen.Generator } func NewServiceWithRepositories( @@ -26,17 +27,19 @@ func NewServiceWithRepositories( securityRuleRepository SecurityGroupRuleRepository, routerRepository RouterRepository, floatingIPRepository FloatingIPRepository, + routerInterfaceRepository RouterInterfaceRepository, idGen idgen.Generator, ) *Service { return &Service{ - networkRepository: networkRepository, - subnetRepository: subnetRepository, - portRepository: portRepository, - securityGroupRepository: securityGroupRepository, - securityRuleRepository: securityRuleRepository, - routerRepository: routerRepository, - floatingIPRepository: floatingIPRepository, - idGen: idGen, + networkRepository: networkRepository, + subnetRepository: subnetRepository, + portRepository: portRepository, + securityGroupRepository: securityGroupRepository, + securityRuleRepository: securityRuleRepository, + routerRepository: routerRepository, + floatingIPRepository: floatingIPRepository, + routerInterfaceRepository: routerInterfaceRepository, + idGen: idGen, } } @@ -51,4 +54,5 @@ func (s *Service) Reset() { s.securityRuleRepository.Reset() s.routerRepository.Reset() s.floatingIPRepository.Reset() + s.routerInterfaceRepository.Reset() } diff --git a/internal/app/network/service_test.go b/internal/app/network/service_test.go index 6ef7145..79bc542 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -112,6 +112,31 @@ func (s *ServiceSuite) TestCreateFloatingIPUsesInjectedIDGenerator() { s.Assert().Equal("DOWN", created.Status) } +func (s *ServiceSuite) TestAddAndRemoveRouterInterfaceUsesInjectedIDGenerator() { + service := newService(idgen.Fixed("router-interface-id")) + router := service.CreateRouter(network.CreateRouter{ + Name: "edge", + ProjectID: "demo", + }) + + added, err := service.AddRouterInterface( + router.ID, + network.RouterInterfaceRequest{SubnetID: "subnet-1"}, + ) + s.Require().NoError(err) + + removed, err := service.RemoveRouterInterface( + router.ID, + network.RouterInterfaceRequest{SubnetID: "subnet-1"}, + ) + s.Require().NoError(err) + + s.Assert().Equal("ri-router-interface-id", added.ID) + s.Assert().Equal("port-router-interface-id", added.PortID) + s.Assert().Equal(added.ID, removed.ID) + s.Assert().Equal("demo", removed.TenantID) +} + func (s *ServiceSuite) TestResetClearsNetworkResources() { service := newService(idgen.Fixed("network-id")) created := service.Create(network.CreateNetwork{Name: "private"}) @@ -151,6 +176,7 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemorySecurityGroupRuleRepository(), storenetwork.NewMemoryRouterRepository(), storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index b69fc61..fc4ff61 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -202,3 +202,16 @@ type CreateFloatingIP struct { SubnetID string ProjectID string } + +type RouterInterface struct { + ID string + RouterID string + SubnetID string + PortID string + TenantID string +} + +type RouterInterfaceRequest struct { + SubnetID string + PortID string +} diff --git a/internal/store/network/memory_router_interface_repository.go b/internal/store/network/memory_router_interface_repository.go new file mode 100644 index 0000000..cca5298 --- /dev/null +++ b/internal/store/network/memory_router_interface_repository.go @@ -0,0 +1,82 @@ +package network + +import ( + "sync" + + appnetwork "github.com/JSYoo5B/SandStack/internal/app/network" +) + +type MemoryRouterInterfaceRepository struct { + mu sync.RWMutex + ids []string + interfaces map[string]appnetwork.RouterInterface +} + +func NewMemoryRouterInterfaceRepository() *MemoryRouterInterfaceRepository { + return &MemoryRouterInterfaceRepository{ + ids: []string{}, + interfaces: map[string]appnetwork.RouterInterface{}, + } +} + +func (r *MemoryRouterInterfaceRepository) Create( + routerInterface appnetwork.RouterInterface, +) appnetwork.RouterInterface { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, routerInterface.ID) + r.interfaces[routerInterface.ID] = routerInterface + + return routerInterface +} + +func (r *MemoryRouterInterfaceRepository) Find( + routerID string, + request appnetwork.RouterInterfaceRequest, +) (appnetwork.RouterInterface, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + for _, id := range r.ids { + routerInterface := r.interfaces[id] + if routerInterface.RouterID != routerID { + continue + } + if request.PortID != "" && routerInterface.PortID == request.PortID { + return routerInterface, nil + } + if request.SubnetID != "" && routerInterface.SubnetID == request.SubnetID { + return routerInterface, nil + } + } + + return appnetwork.RouterInterface{}, appnetwork.ErrRouterInterfaceNotFound +} + +func (r *MemoryRouterInterfaceRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.interfaces[id]; !ok { + return appnetwork.ErrRouterInterfaceNotFound + } + + delete(r.interfaces, 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 *MemoryRouterInterfaceRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = []string{} + r.interfaces = map[string]appnetwork.RouterInterface{} +}