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/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 e340b0a..d86a44c 100644 --- a/internal/api/network/router.go +++ b/internal/api/network/router.go @@ -33,6 +33,11 @@ func NewHandler(cfg config.Config) Handler { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), idgen.Random(), ), ) @@ -63,6 +68,24 @@ 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) + 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) + router.Get("/routers", h.listRouters) + 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) + router.Delete("/floatingips/{floating_ip_id}", h.deleteFloatingIP) return router } 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/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..697bb35 --- /dev/null +++ b/internal/api/network/security_group_dto.go @@ -0,0 +1,115 @@ +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"` + 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( + 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, + 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, + }) + } + + return documents +} 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/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..136bfa7 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -61,6 +61,11 @@ func NewRouter(cfg config.Config) http.Handler { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), 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/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 7d414e5..b31b464 100644 --- a/internal/app/network/repository.go +++ b/internal/app/network/repository.go @@ -24,3 +24,42 @@ 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() +} + +type SecurityGroupRuleRepository interface { + Create(rule SecurityGroupRule) SecurityGroupRule + List() []SecurityGroupRule + Get(id string) (SecurityGroupRule, error) + Delete(id string) error + Reset() +} + +type RouterRepository interface { + Create(router Router) Router + List() []Router + Get(id string) (Router, error) + Delete(id string) error + Reset() +} + +type FloatingIPRepository interface { + Create(floatingIP FloatingIP) FloatingIP + List() []FloatingIP + Get(id string) (FloatingIP, error) + 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/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/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 c739476..2a47e58 100644 --- a/internal/app/network/service.go +++ b/internal/app/network/service.go @@ -7,24 +7,39 @@ 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 + securityRuleRepository SecurityGroupRuleRepository + routerRepository RouterRepository + floatingIPRepository FloatingIPRepository + routerInterfaceRepository RouterInterfaceRepository + idGen idgen.Generator } func NewServiceWithRepositories( networkRepository NetworkRepository, subnetRepository SubnetRepository, portRepository PortRepository, + securityGroupRepository SecurityGroupRepository, + securityRuleRepository SecurityGroupRuleRepository, + routerRepository RouterRepository, + floatingIPRepository FloatingIPRepository, + routerInterfaceRepository RouterInterfaceRepository, idGen idgen.Generator, ) *Service { return &Service{ - networkRepository: networkRepository, - subnetRepository: subnetRepository, - portRepository: portRepository, - idGen: idGen, + networkRepository: networkRepository, + subnetRepository: subnetRepository, + portRepository: portRepository, + securityGroupRepository: securityGroupRepository, + securityRuleRepository: securityRuleRepository, + routerRepository: routerRepository, + floatingIPRepository: floatingIPRepository, + routerInterfaceRepository: routerInterfaceRepository, + idGen: idGen, } } @@ -35,4 +50,9 @@ func (s *Service) Reset() { s.networkRepository.Reset() s.subnetRepository.Reset() s.portRepository.Reset() + s.securityGroupRepository.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 69bce13..79bc542 100644 --- a/internal/app/network/service_test.go +++ b/internal/app/network/service_test.go @@ -48,17 +48,123 @@ 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) 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) 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) 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) 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"}) service.CreateSubnet(network.CreateSubnet{NetworkID: created.ID}) service.CreatePort(network.CreatePort{NetworkID: created.ID}) + securityGroup := service.CreateSecurityGroup(network.CreateSecurityGroup{ + Name: "default", + }) + _, err := service.CreateSecurityGroupRule(network.CreateSecurityGroupRule{ + Direction: "ingress", + EtherType: "IPv4", + SecurityGroupID: securityGroup.ID, + }) + s.Require().NoError(err) + service.CreateRouter(network.CreateRouter{Name: "edge"}) + service.CreateFloatingIP(network.CreateFloatingIP{ + FloatingNetworkID: created.ID, + }) service.Reset() s.Assert().Empty(service.List()) s.Assert().Empty(service.ListSubnets()) s.Assert().Empty(service.ListPorts()) + 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 { @@ -66,6 +172,11 @@ func newService(idGen idgen.Generator) *network.Service { storenetwork.NewMemoryNetworkRepository(), storenetwork.NewMemorySubnetRepository(), storenetwork.NewMemoryPortRepository(), + storenetwork.NewMemorySecurityGroupRepository(), + storenetwork.NewMemorySecurityGroupRuleRepository(), + storenetwork.NewMemoryRouterRepository(), + storenetwork.NewMemoryFloatingIPRepository(), + storenetwork.NewMemoryRouterInterfaceRepository(), idGen, ) } diff --git a/internal/app/network/types.go b/internal/app/network/types.go index 94d1a09..fc4ff61 100644 --- a/internal/app/network/types.go +++ b/internal/app/network/types.go @@ -88,3 +88,130 @@ 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 + 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 +} + +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 +} + +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 +} + +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_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{} +} 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{} +} 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{} +} 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{} +} 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{} +}