diff --git a/internal/api/compute/action.go b/internal/api/compute/action.go new file mode 100644 index 0000000..26f74bd --- /dev/null +++ b/internal/api/compute/action.go @@ -0,0 +1,53 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) actionServer(w http.ResponseWriter, r *http.Request) { + var request serverActionRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + var err error + switch { + case request.Has("os-start"): + err = h.service.StartServer(chi.URLParam(r, "server_id")) + case request.Has("os-stop"): + err = h.service.StopServer(chi.URLParam(r, "server_id")) + case request.Has("reboot"): + err = h.service.RebootServer(chi.URLParam(r, "server_id")) + case request.Has("addSecurityGroup"): + err = h.service.AddServerSecurityGroup( + chi.URLParam(r, "server_id"), + request.SecurityGroupName("addSecurityGroup"), + ) + case request.Has("removeSecurityGroup"): + err = h.service.RemoveServerSecurityGroup( + chi.URLParam(r, "server_id"), + request.SecurityGroupName("removeSecurityGroup"), + ) + default: + respond.Error(w, http.StatusBadRequest, "unsupported server action") + return + } + + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server action failed") + return + } + + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/api/compute/action_dto.go b/internal/api/compute/action_dto.go new file mode 100644 index 0000000..b129dac --- /dev/null +++ b/internal/api/compute/action_dto.go @@ -0,0 +1,18 @@ +package compute + +import "encoding/json" + +type serverActionRequest map[string]json.RawMessage + +func (r serverActionRequest) Has(action string) bool { + _, ok := r[action] + return ok +} + +func (r serverActionRequest) SecurityGroupName(action string) string { + var body struct { + Name string `json:"name"` + } + _ = json.Unmarshal(r[action], &body) + return body.Name +} diff --git a/internal/api/compute/action_test.go b/internal/api/compute/action_test.go new file mode 100644 index 0000000..3117486 --- /dev/null +++ b/internal/api/compute/action_test.go @@ -0,0 +1,126 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + storecompute "github.com/JSYoo5B/SandStack/internal/store/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/secgroups" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/stretchr/testify/suite" +) + +type ActionSuite struct { + suite.Suite + server *httptest.Server + service *appcompute.Service +} + +func TestActionSuite(t *testing.T) { + suite.Run(t, new(ActionSuite)) +} + +func (s *ActionSuite) SetupTest() { + s.service = appcompute.NewServiceWithRuntime( + storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), + clock.Wall(), + idgen.Random(), + ) + s.server = httptest.NewServer( + compute.NewRouterWithService( + testhelper.DefaultConfig(), + s.service, + ), + ) +} + +func (s *ActionSuite) TearDownTest() { + s.server.Close() +} + +func (s *ActionSuite) TestStopAndStartServer() { + created := s.service.CreateServer(appcompute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + client := testhelper.ServiceClient(s.server.URL + "/demo") + + err := servers.Stop( + s.T().Context(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + stopped, err := s.service.GetServer(created.ID) + s.Require().NoError(err) + s.Assert().Equal("SHUTOFF", stopped.Status) + + err = servers.Start( + s.T().Context(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) + + started, err := s.service.GetServer(created.ID) + s.Require().NoError(err) + s.Assert().Equal("ACTIVE", started.Status) +} + +func (s *ActionSuite) TestRebootServer() { + created := s.service.CreateServer(appcompute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + client := testhelper.ServiceClient(s.server.URL + "/demo") + + err := servers.Reboot( + s.T().Context(), + client, + created.ID, + servers.RebootOpts{Type: servers.SoftReboot}, + ).ExtractErr() + s.Require().NoError(err) + + rebooted, err := s.service.GetServer(created.ID) + s.Require().NoError(err) + + s.Assert().Equal("ACTIVE", rebooted.Status) + s.Assert().Equal(100, rebooted.Progress) +} + +func (s *ActionSuite) TestAddAndRemoveServerSecurityGroup() { + created := s.service.CreateServer(appcompute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + }) + client := testhelper.ServiceClient(s.server.URL + "/demo") + + err := secgroups.AddServer( + s.T().Context(), + client, + created.ID, + "default", + ).ExtractErr() + s.Require().NoError(err) + + err = secgroups.RemoveServer( + s.T().Context(), + client, + created.ID, + "default", + ).ExtractErr() + s.Require().NoError(err) +} diff --git a/internal/api/compute/address.go b/internal/api/compute/address.go new file mode 100644 index 0000000..7a6eb2a --- /dev/null +++ b/internal/api/compute/address.go @@ -0,0 +1,46 @@ +package compute + +import ( + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listServerAddresses(w http.ResponseWriter, r *http.Request) { + addresses, err := h.service.ServerAddresses(chi.URLParam(r, "server_id")) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server address lookup failed") + return + } + + respond.JSON(w, http.StatusOK, serverAddressListResponse{ + Addresses: toServerAddressDocumentsByNetwork(addresses), + }) +} + +func (h Handler) listServerAddressesByNetwork(w http.ResponseWriter, r *http.Request) { + addresses, err := h.service.ServerAddressesByNetwork( + chi.URLParam(r, "server_id"), + chi.URLParam(r, "network"), + ) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server address lookup failed") + return + } + + network := chi.URLParam(r, "network") + respond.JSON(w, http.StatusOK, map[string][]serverAddressDocument{ + network: toServerAddressDocuments(addresses), + }) +} diff --git a/internal/api/compute/address_dto.go b/internal/api/compute/address_dto.go new file mode 100644 index 0000000..6633439 --- /dev/null +++ b/internal/api/compute/address_dto.go @@ -0,0 +1,37 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type serverAddressListResponse struct { + Addresses map[string][]serverAddressDocument `json:"addresses"` +} + +type serverAddressDocument struct { + Version int `json:"version"` + Address string `json:"addr"` +} + +func toServerAddressDocumentsByNetwork( + addresses map[string][]appcompute.ServerAddress, +) map[string][]serverAddressDocument { + documents := make(map[string][]serverAddressDocument, len(addresses)) + for network, values := range addresses { + documents[network] = toServerAddressDocuments(values) + } + + return documents +} + +func toServerAddressDocuments( + addresses []appcompute.ServerAddress, +) []serverAddressDocument { + documents := make([]serverAddressDocument, 0, len(addresses)) + for _, address := range addresses { + documents = append(documents, serverAddressDocument{ + Version: address.Version, + Address: address.Address, + }) + } + + return documents +} diff --git a/internal/api/compute/address_test.go b/internal/api/compute/address_test.go new file mode 100644 index 0000000..a7d311b --- /dev/null +++ b/internal/api/compute/address_test.go @@ -0,0 +1,81 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/stretchr/testify/suite" +) + +type AddressSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAddressSuite(t *testing.T) { + suite.Run(t, new(AddressSuite)) +} + +func (s *AddressSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AddressSuite) TearDownTest() { + s.server.Close() +} + +func (s *AddressSuite) TestListServerAddresses() { + created := s.createServer("web") + + pages, err := servers.ListAddresses( + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + addresses, err := servers.ExtractAddresses(pages) + s.Require().NoError(err) + s.Require().Contains(addresses, "private") + s.Require().Len(addresses["private"], 1) + s.Assert().Equal(4, addresses["private"][0].Version) + s.Assert().Equal("10.0.0.10", addresses["private"][0].Address) +} + +func (s *AddressSuite) TestListServerAddressesByNetwork() { + created := s.createServer("web") + + pages, err := servers.ListAddressesByNetwork( + testhelper.ServiceClient(s.server.URL+"/demo"), + created.ID, + "private", + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + addresses, err := servers.ExtractNetworkAddresses(pages) + s.Require().NoError(err) + s.Require().Len(addresses, 1) + s.Assert().Equal(4, addresses[0].Version) + s.Assert().Equal("10.0.0.10", addresses[0].Address) +} + +func (s *AddressSuite) createServer(name string) *servers.Server { + created, err := servers.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + servers.CreateOpts{ + Name: name, + ImageRef: "img-1", + FlavorRef: "1", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/compute/aggregate.go b/internal/api/compute/aggregate.go new file mode 100644 index 0000000..98d345c --- /dev/null +++ b/internal/api/compute/aggregate.go @@ -0,0 +1,82 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listAggregates(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, aggregateListResponse{ + Aggregates: toAggregateDocuments(h.service.ListAggregates()), + }) +} + +func (h Handler) createAggregate(w http.ResponseWriter, r *http.Request) { + var request createAggregateRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid aggregate request") + return + } + + aggregate := h.service.CreateAggregate(request.createAggregate()) + + respond.JSON(w, http.StatusOK, aggregateResponse{ + Aggregate: toAggregateDocument(aggregate), + }) +} + +func (h Handler) getAggregate(w http.ResponseWriter, r *http.Request) { + id, ok := aggregateID(w, r) + if !ok { + return + } + + aggregate, err := h.service.GetAggregate(id) + if errors.Is(err, appcompute.ErrAggregateNotFound) { + respond.Error(w, http.StatusNotFound, "aggregate not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "aggregate lookup failed") + return + } + + respond.JSON(w, http.StatusOK, aggregateResponse{ + Aggregate: toAggregateDocument(aggregate), + }) +} + +func (h Handler) deleteAggregate(w http.ResponseWriter, r *http.Request) { + id, ok := aggregateID(w, r) + if !ok { + return + } + + err := h.service.DeleteAggregate(id) + if errors.Is(err, appcompute.ErrAggregateNotFound) { + respond.Error(w, http.StatusNotFound, "aggregate not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "aggregate delete failed") + return + } + + respond.JSON(w, http.StatusOK, map[string]any{}) +} + +func aggregateID(w http.ResponseWriter, r *http.Request) (int, bool) { + id, err := strconv.Atoi(chi.URLParam(r, "aggregate_id")) + if err != nil { + respond.Error(w, http.StatusBadRequest, "invalid aggregate id") + return 0, false + } + + return id, true +} diff --git a/internal/api/compute/aggregate_dto.go b/internal/api/compute/aggregate_dto.go new file mode 100644 index 0000000..59e4257 --- /dev/null +++ b/internal/api/compute/aggregate_dto.go @@ -0,0 +1,71 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type createAggregateRequest struct { + Aggregate createAggregateDocument `json:"aggregate"` +} + +type createAggregateDocument struct { + Name string `json:"name"` + AvailabilityZone string `json:"availability_zone"` +} + +type aggregateListResponse struct { + Aggregates []aggregateDocument `json:"aggregates"` +} + +type aggregateResponse struct { + Aggregate aggregateDocument `json:"aggregate"` +} + +type aggregateDocument struct { + AvailabilityZone string `json:"availability_zone"` + Hosts []string `json:"hosts"` + ID int `json:"id"` + Metadata map[string]string `json:"metadata"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + DeletedAt *string `json:"deleted_at"` + Deleted bool `json:"deleted"` + UUID string `json:"uuid"` +} + +func (request createAggregateRequest) createAggregate() appcompute.CreateAggregate { + return appcompute.CreateAggregate{ + Name: request.Aggregate.Name, + AvailabilityZone: request.Aggregate.AvailabilityZone, + } +} + +func toAggregateDocuments( + aggregates []appcompute.Aggregate, +) []aggregateDocument { + documents := make([]aggregateDocument, 0, len(aggregates)) + for _, aggregate := range aggregates { + documents = append(documents, toAggregateDocument(aggregate)) + } + + return documents +} + +func toAggregateDocument(aggregate appcompute.Aggregate) aggregateDocument { + var deletedAt *string + if aggregate.DeletedAt != "" { + deletedAt = &aggregate.DeletedAt + } + + return aggregateDocument{ + AvailabilityZone: aggregate.AvailabilityZone, + Hosts: aggregate.Hosts, + ID: aggregate.ID, + Metadata: aggregate.Metadata, + Name: aggregate.Name, + CreatedAt: aggregate.CreatedAt, + UpdatedAt: aggregate.UpdatedAt, + DeletedAt: deletedAt, + Deleted: aggregate.Deleted, + UUID: aggregate.UUID, + } +} diff --git a/internal/api/compute/aggregate_test.go b/internal/api/compute/aggregate_test.go new file mode 100644 index 0000000..e02fb20 --- /dev/null +++ b/internal/api/compute/aggregate_test.go @@ -0,0 +1,70 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/aggregates" + "github.com/stretchr/testify/suite" +) + +type AggregateSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAggregateSuite(t *testing.T) { + suite.Run(t, new(AggregateSuite)) +} + +func (s *AggregateSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AggregateSuite) TearDownTest() { + s.server.Close() +} + +func (s *AggregateSuite) TestCreateListGetAndDeleteAggregate() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := aggregates.Create( + s.T().Context(), + client, + aggregates.CreateOpts{ + Name: "az-one", + AvailabilityZone: "nova", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + pages, err := aggregates.List(client).AllPages(s.T().Context()) + s.Require().NoError(err) + listed, err := aggregates.ExtractAggregates(pages) + s.Require().NoError(err) + found, err := aggregates.Get( + s.T().Context(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(1, created.ID) + s.Assert().Equal("az-one", created.Name) + s.Assert().Equal("nova", created.AvailabilityZone) + s.Require().Len(listed, 1) + s.Assert().Equal(created.ID, listed[0].ID) + s.Assert().Equal(created.ID, found.ID) + + err = aggregates.Delete( + s.T().Context(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) +} diff --git a/internal/api/compute/availability_zone.go b/internal/api/compute/availability_zone.go new file mode 100644 index 0000000..b853361 --- /dev/null +++ b/internal/api/compute/availability_zone.go @@ -0,0 +1,15 @@ +package compute + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listAvailabilityZones(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, availabilityZoneListResponse{ + AvailabilityZoneInfo: toAvailabilityZoneDocuments( + h.service.ListAvailabilityZones(), + ), + }) +} diff --git a/internal/api/compute/availability_zone_dto.go b/internal/api/compute/availability_zone_dto.go new file mode 100644 index 0000000..c4cd5d1 --- /dev/null +++ b/internal/api/compute/availability_zone_dto.go @@ -0,0 +1,46 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type availabilityZoneListResponse struct { + AvailabilityZoneInfo []availabilityZoneDocument `json:"availabilityZoneInfo"` +} + +type availabilityZoneDocument struct { + Hosts map[string]map[string]availabilityZoneServiceState `json:"hosts"` + ZoneName string `json:"zoneName"` + ZoneState availabilityZoneState `json:"zoneState"` +} + +type availabilityZoneState struct { + Available bool `json:"available"` +} + +type availabilityZoneServiceState struct { + Active bool `json:"active"` + Available bool `json:"available"` + UpdatedAt string `json:"updated_at"` +} + +func toAvailabilityZoneDocuments( + zones []appcompute.AvailabilityZone, +) []availabilityZoneDocument { + documents := make([]availabilityZoneDocument, 0, len(zones)) + for _, zone := range zones { + documents = append(documents, toAvailabilityZoneDocument(zone)) + } + + return documents +} + +func toAvailabilityZoneDocument( + zone appcompute.AvailabilityZone, +) availabilityZoneDocument { + return availabilityZoneDocument{ + Hosts: map[string]map[string]availabilityZoneServiceState{}, + ZoneName: zone.Name, + ZoneState: availabilityZoneState{ + Available: zone.Available, + }, + } +} diff --git a/internal/api/compute/availability_zone_test.go b/internal/api/compute/availability_zone_test.go new file mode 100644 index 0000000..26c9706 --- /dev/null +++ b/internal/api/compute/availability_zone_test.go @@ -0,0 +1,58 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/availabilityzones" + "github.com/stretchr/testify/suite" +) + +type AvailabilityZoneSuite struct { + suite.Suite + server *httptest.Server +} + +func TestAvailabilityZoneSuite(t *testing.T) { + suite.Run(t, new(AvailabilityZoneSuite)) +} + +func (s *AvailabilityZoneSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *AvailabilityZoneSuite) TearDownTest() { + s.server.Close() +} + +func (s *AvailabilityZoneSuite) TestListAvailabilityZones() { + pages, err := availabilityzones.List( + testhelper.ServiceClient(s.server.URL + "/demo"), + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := availabilityzones.ExtractAvailabilityZones(pages) + s.Require().NoError(err) + + s.Require().Len(list, 1) + s.Assert().Equal("nova", list[0].ZoneName) + s.Assert().True(list[0].ZoneState.Available) +} + +func (s *AvailabilityZoneSuite) TestListAvailabilityZoneDetails() { + pages, err := availabilityzones.ListDetail( + testhelper.ServiceClient(s.server.URL + "/demo"), + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := availabilityzones.ExtractAvailabilityZones(pages) + s.Require().NoError(err) + + s.Require().Len(list, 1) + s.Assert().Equal("nova", list[0].ZoneName) + s.Assert().Empty(list[0].Hosts) +} diff --git a/internal/api/compute/flavor_extra_spec.go b/internal/api/compute/flavor_extra_spec.go new file mode 100644 index 0000000..7b9a51a --- /dev/null +++ b/internal/api/compute/flavor_extra_spec.go @@ -0,0 +1,112 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listFlavorExtraSpecs(w http.ResponseWriter, r *http.Request) { + extraSpecs, err := h.service.ListFlavorExtraSpecs( + chi.URLParam(r, "flavor_id"), + ) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor lookup failed") + return + } + + respond.JSON(w, http.StatusOK, flavorExtraSpecListResponse{ + ExtraSpecs: extraSpecs, + }) +} + +func (h Handler) getFlavorExtraSpec(w http.ResponseWriter, r *http.Request) { + extraSpec, err := h.service.GetFlavorExtraSpec( + chi.URLParam(r, "flavor_id"), + chi.URLParam(r, "key"), + ) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor extra spec not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor lookup failed") + return + } + + respond.JSON(w, http.StatusOK, extraSpec) +} + +func (h Handler) createFlavorExtraSpecs(w http.ResponseWriter, r *http.Request) { + var request createFlavorExtraSpecsRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid flavor extra spec request") + return + } + + extraSpecs, err := h.service.CreateFlavorExtraSpecs( + chi.URLParam(r, "flavor_id"), + request.ExtraSpecs, + ) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor update failed") + return + } + + respond.JSON(w, http.StatusOK, flavorExtraSpecListResponse{ + ExtraSpecs: extraSpecs, + }) +} + +func (h Handler) updateFlavorExtraSpec(w http.ResponseWriter, r *http.Request) { + var request map[string]string + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid flavor extra spec request") + return + } + + extraSpec, err := h.service.UpdateFlavorExtraSpec( + chi.URLParam(r, "flavor_id"), + chi.URLParam(r, "key"), + request, + ) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor extra spec not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor update failed") + return + } + + respond.JSON(w, http.StatusOK, extraSpec) +} + +func (h Handler) deleteFlavorExtraSpec(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteFlavorExtraSpec( + chi.URLParam(r, "flavor_id"), + chi.URLParam(r, "key"), + ) + if errors.Is(err, appcompute.ErrFlavorNotFound) { + respond.Error(w, http.StatusNotFound, "flavor not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "flavor update failed") + return + } + + respond.JSON(w, http.StatusOK, map[string]any{}) +} diff --git a/internal/api/compute/flavor_extra_spec_dto.go b/internal/api/compute/flavor_extra_spec_dto.go new file mode 100644 index 0000000..2d52126 --- /dev/null +++ b/internal/api/compute/flavor_extra_spec_dto.go @@ -0,0 +1,9 @@ +package compute + +type flavorExtraSpecListResponse struct { + ExtraSpecs map[string]string `json:"extra_specs"` +} + +type createFlavorExtraSpecsRequest struct { + ExtraSpecs map[string]string `json:"extra_specs"` +} diff --git a/internal/api/compute/flavor_extra_spec_test.go b/internal/api/compute/flavor_extra_spec_test.go new file mode 100644 index 0000000..62b98c2 --- /dev/null +++ b/internal/api/compute/flavor_extra_spec_test.go @@ -0,0 +1,80 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/stretchr/testify/suite" +) + +type FlavorExtraSpecSuite struct { + suite.Suite + server *httptest.Server +} + +func TestFlavorExtraSpecSuite(t *testing.T) { + suite.Run(t, new(FlavorExtraSpecSuite)) +} + +func (s *FlavorExtraSpecSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *FlavorExtraSpecSuite) TearDownTest() { + s.server.Close() +} + +func (s *FlavorExtraSpecSuite) TestManageFlavorExtraSpecs() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := flavors.CreateExtraSpecs( + s.T().Context(), + client, + "1", + flavors.ExtraSpecsOpts{ + "hw:cpu_policy": "shared", + }, + ).Extract() + s.Require().NoError(err) + s.Assert().Equal("shared", created["hw:cpu_policy"]) + + list, err := flavors.ListExtraSpecs( + s.T().Context(), + client, + "1", + ).Extract() + s.Require().NoError(err) + s.Assert().Equal("shared", list["hw:cpu_policy"]) + + found, err := flavors.GetExtraSpec( + s.T().Context(), + client, + "1", + "hw:cpu_policy", + ).Extract() + s.Require().NoError(err) + s.Assert().Equal("shared", found["hw:cpu_policy"]) + + updated, err := flavors.UpdateExtraSpec( + s.T().Context(), + client, + "1", + flavors.ExtraSpecsOpts{ + "hw:cpu_policy": "dedicated", + }, + ).Extract() + s.Require().NoError(err) + s.Assert().Equal("dedicated", updated["hw:cpu_policy"]) + + err = flavors.DeleteExtraSpec( + s.T().Context(), + client, + "1", + "hw:cpu_policy", + ).ExtractErr() + s.Require().NoError(err) +} diff --git a/internal/api/compute/hypervisor.go b/internal/api/compute/hypervisor.go new file mode 100644 index 0000000..47ed02a --- /dev/null +++ b/internal/api/compute/hypervisor.go @@ -0,0 +1,40 @@ +package compute + +import ( + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listHypervisors(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, hypervisorListResponse{ + Hypervisors: toHypervisorDocuments(h.service.ListHypervisors()), + }) +} + +func (h Handler) getHypervisorStatistics(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, hypervisorStatisticsResponse{ + HypervisorStatistics: toHypervisorStatisticsDocument( + h.service.ListHypervisors(), + ), + }) +} + +func (h Handler) getHypervisor(w http.ResponseWriter, r *http.Request) { + hypervisor, err := h.service.GetHypervisor(chi.URLParam(r, "hypervisor_id")) + if errors.Is(err, appcompute.ErrHypervisorNotFound) { + respond.Error(w, http.StatusNotFound, "hypervisor not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "hypervisor lookup failed") + return + } + + respond.JSON(w, http.StatusOK, hypervisorResponse{ + Hypervisor: toHypervisorDocument(hypervisor), + }) +} diff --git a/internal/api/compute/hypervisor_dto.go b/internal/api/compute/hypervisor_dto.go new file mode 100644 index 0000000..5dff3e5 --- /dev/null +++ b/internal/api/compute/hypervisor_dto.go @@ -0,0 +1,151 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type hypervisorListResponse struct { + Hypervisors []hypervisorDocument `json:"hypervisors"` +} + +type hypervisorResponse struct { + Hypervisor hypervisorDocument `json:"hypervisor"` +} + +type hypervisorStatisticsResponse struct { + HypervisorStatistics hypervisorStatisticsDocument `json:"hypervisor_statistics"` +} + +type hypervisorDocument struct { + ID string `json:"id"` + CPUInfo hypervisorCPUInfoDocument `json:"cpu_info"` + CurrentWorkload int `json:"current_workload"` + Status string `json:"status"` + State string `json:"state"` + DiskAvailableLeast int `json:"disk_available_least"` + HostIP string `json:"host_ip"` + FreeDiskGB int `json:"free_disk_gb"` + FreeRAMMB int `json:"free_ram_mb"` + HypervisorHostname string `json:"hypervisor_hostname"` + HypervisorType string `json:"hypervisor_type"` + HypervisorVersion int `json:"hypervisor_version"` + LocalGB int `json:"local_gb"` + LocalGBUsed int `json:"local_gb_used"` + MemoryMB int `json:"memory_mb"` + MemoryMBUsed int `json:"memory_mb_used"` + RunningVMs int `json:"running_vms"` + Service hypervisorServiceDocument `json:"service"` + VCPUs int `json:"vcpus"` + VCPUsUsed int `json:"vcpus_used"` +} + +type hypervisorCPUInfoDocument struct { + Vendor string `json:"vendor"` + Arch string `json:"arch"` + Model string `json:"model"` + Features []string `json:"features"` + Topology hypervisorCPUTopologyDocument `json:"topology"` +} + +type hypervisorCPUTopologyDocument struct { + Cells int `json:"cells"` + Sockets int `json:"sockets"` + Cores int `json:"cores"` + Threads int `json:"threads"` +} + +type hypervisorServiceDocument struct { + ID string `json:"id"` + Host string `json:"host"` + DisabledReason string `json:"disabled_reason"` +} + +type hypervisorStatisticsDocument struct { + Count int `json:"count"` + CurrentWorkload int `json:"current_workload"` + DiskAvailableLeast int `json:"disk_available_least"` + FreeDiskGB int `json:"free_disk_gb"` + FreeRAMMB int `json:"free_ram_mb"` + LocalGB int `json:"local_gb"` + LocalGBUsed int `json:"local_gb_used"` + MemoryMB int `json:"memory_mb"` + MemoryMBUsed int `json:"memory_mb_used"` + RunningVMs int `json:"running_vms"` + VCPUs int `json:"vcpus"` + VCPUsUsed int `json:"vcpus_used"` +} + +func toHypervisorDocuments( + hypervisors []appcompute.Hypervisor, +) []hypervisorDocument { + documents := make([]hypervisorDocument, 0, len(hypervisors)) + for _, hypervisor := range hypervisors { + documents = append(documents, toHypervisorDocument(hypervisor)) + } + + return documents +} + +func toHypervisorDocument( + hypervisor appcompute.Hypervisor, +) hypervisorDocument { + return hypervisorDocument{ + ID: hypervisor.ID, + CPUInfo: defaultCPUInfo(), + CurrentWorkload: hypervisor.CurrentWorkload, + Status: hypervisor.Status, + State: hypervisor.State, + DiskAvailableLeast: hypervisor.DiskAvailableLeast, + HostIP: hypervisor.HostIP, + FreeDiskGB: hypervisor.FreeDiskGB, + FreeRAMMB: hypervisor.FreeRAMMB, + HypervisorHostname: hypervisor.Hostname, + HypervisorType: hypervisor.Type, + HypervisorVersion: hypervisor.Version, + LocalGB: hypervisor.LocalGB, + LocalGBUsed: hypervisor.LocalGBUsed, + MemoryMB: hypervisor.MemoryMB, + MemoryMBUsed: hypervisor.MemoryMBUsed, + RunningVMs: hypervisor.RunningVMs, + Service: hypervisorServiceDocument{ + ID: hypervisor.ServiceID, + Host: hypervisor.Hostname, + }, + VCPUs: hypervisor.VCPUs, + VCPUsUsed: hypervisor.VCPUsUsed, + } +} + +func toHypervisorStatisticsDocument( + hypervisors []appcompute.Hypervisor, +) hypervisorStatisticsDocument { + stats := hypervisorStatisticsDocument{Count: len(hypervisors)} + for _, hypervisor := range hypervisors { + stats.CurrentWorkload += hypervisor.CurrentWorkload + stats.DiskAvailableLeast += hypervisor.DiskAvailableLeast + stats.FreeDiskGB += hypervisor.FreeDiskGB + stats.FreeRAMMB += hypervisor.FreeRAMMB + stats.LocalGB += hypervisor.LocalGB + stats.LocalGBUsed += hypervisor.LocalGBUsed + stats.MemoryMB += hypervisor.MemoryMB + stats.MemoryMBUsed += hypervisor.MemoryMBUsed + stats.RunningVMs += hypervisor.RunningVMs + stats.VCPUs += hypervisor.VCPUs + stats.VCPUsUsed += hypervisor.VCPUsUsed + } + + return stats +} + +func defaultCPUInfo() hypervisorCPUInfoDocument { + return hypervisorCPUInfoDocument{ + Vendor: "SandStack", + Arch: "x86_64", + Model: "compat", + Features: []string{}, + Topology: hypervisorCPUTopologyDocument{ + Cells: 1, + Sockets: 1, + Cores: 1, + Threads: 1, + }, + } +} diff --git a/internal/api/compute/hypervisor_test.go b/internal/api/compute/hypervisor_test.go new file mode 100644 index 0000000..a8d37e2 --- /dev/null +++ b/internal/api/compute/hypervisor_test.go @@ -0,0 +1,73 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/hypervisors" + "github.com/stretchr/testify/suite" +) + +type HypervisorSuite struct { + suite.Suite + server *httptest.Server +} + +func TestHypervisorSuite(t *testing.T) { + suite.Run(t, new(HypervisorSuite)) +} + +func (s *HypervisorSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *HypervisorSuite) TearDownTest() { + s.server.Close() +} + +func (s *HypervisorSuite) TestListHypervisors() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + pages, err := hypervisors.List( + client, + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := hypervisors.ExtractHypervisors(pages) + s.Require().NoError(err) + + s.Require().Len(listed, 1) + s.Assert().Equal("1", listed[0].ID) + s.Assert().Equal("sandstack", listed[0].HypervisorHostname) + s.Assert().Equal("enabled", listed[0].Status) +} + +func (s *HypervisorSuite) TestGetHypervisor() { + found, err := hypervisors.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + "1", + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal("1", found.ID) + s.Assert().Equal("sandstack", found.HypervisorHostname) +} + +func (s *HypervisorSuite) TestGetHypervisorStatistics() { + stats, err := hypervisors.GetStatistics( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(stats) + + s.Assert().Equal(1, stats.Count) + s.Assert().Equal(200, stats.VCPUs) + s.Assert().Equal(512000, stats.MemoryMB) +} diff --git a/internal/api/compute/keypair.go b/internal/api/compute/keypair.go new file mode 100644 index 0000000..e37df90 --- /dev/null +++ b/internal/api/compute/keypair.go @@ -0,0 +1,60 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listKeyPairs(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, keyPairListResponse{ + KeyPairs: toKeyPairListDocuments(h.service.ListKeyPairs()), + }) +} + +func (h Handler) createKeyPair(w http.ResponseWriter, r *http.Request) { + var request createKeyPairRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + keyPair := h.service.CreateKeyPair(request.createKeyPair()) + respond.JSON(w, http.StatusCreated, keyPairResponse{ + KeyPair: toKeyPairDocument(keyPair), + }) +} + +func (h Handler) getKeyPair(w http.ResponseWriter, r *http.Request) { + keyPair, err := h.service.GetKeyPair(chi.URLParam(r, "keypair_name")) + if errors.Is(err, appcompute.ErrKeyPairNotFound) { + respond.Error(w, http.StatusNotFound, "key pair not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "key pair lookup failed") + return + } + + respond.JSON(w, http.StatusOK, keyPairResponse{ + KeyPair: toKeyPairDocument(keyPair), + }) +} + +func (h Handler) deleteKeyPair(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteKeyPair(chi.URLParam(r, "keypair_name")) + if errors.Is(err, appcompute.ErrKeyPairNotFound) { + respond.Error(w, http.StatusNotFound, "key pair not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "key pair delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/compute/keypair_dto.go b/internal/api/compute/keypair_dto.go new file mode 100644 index 0000000..a5cbf20 --- /dev/null +++ b/internal/api/compute/keypair_dto.go @@ -0,0 +1,68 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type createKeyPairRequest struct { + KeyPair createKeyPairDocument `json:"keypair"` +} + +type createKeyPairDocument struct { + Name string `json:"name"` + UserID string `json:"user_id"` + Type string `json:"type"` + PublicKey string `json:"public_key"` +} + +func (r createKeyPairRequest) createKeyPair() appcompute.CreateKeyPair { + return appcompute.CreateKeyPair{ + Name: r.KeyPair.Name, + UserID: r.KeyPair.UserID, + Type: r.KeyPair.Type, + PublicKey: r.KeyPair.PublicKey, + } +} + +type keyPairListResponse struct { + KeyPairs []keyPairListDocument `json:"keypairs"` +} + +type keyPairListDocument struct { + KeyPair keyPairDocument `json:"keypair"` +} + +type keyPairResponse struct { + KeyPair keyPairDocument `json:"keypair"` +} + +type keyPairDocument struct { + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` + PublicKey string `json:"public_key"` + PrivateKey string `json:"private_key,omitempty"` + UserID string `json:"user_id"` + Type string `json:"type"` +} + +func toKeyPairListDocuments( + keyPairs []appcompute.KeyPair, +) []keyPairListDocument { + documents := make([]keyPairListDocument, 0, len(keyPairs)) + for _, keyPair := range keyPairs { + documents = append(documents, keyPairListDocument{ + KeyPair: toKeyPairDocument(keyPair), + }) + } + + return documents +} + +func toKeyPairDocument(keyPair appcompute.KeyPair) keyPairDocument { + return keyPairDocument{ + Name: keyPair.Name, + Fingerprint: keyPair.Fingerprint, + PublicKey: keyPair.PublicKey, + PrivateKey: keyPair.PrivateKey, + UserID: keyPair.UserID, + Type: keyPair.Type, + } +} diff --git a/internal/api/compute/keypair_test.go b/internal/api/compute/keypair_test.go new file mode 100644 index 0000000..19a5e2e --- /dev/null +++ b/internal/api/compute/keypair_test.go @@ -0,0 +1,108 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" + "github.com/stretchr/testify/suite" +) + +type KeyPairSuite struct { + suite.Suite + server *httptest.Server +} + +func TestKeyPairSuite(t *testing.T) { + suite.Run(t, new(KeyPairSuite)) +} + +func (s *KeyPairSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *KeyPairSuite) TearDownTest() { + s.server.Close() +} + +func (s *KeyPairSuite) TestListKeyPairs() { + list := s.listKeyPairs() + + s.Assert().Empty(list) +} + +func (s *KeyPairSuite) TestCreateKeyPairThenListKeyPairs() { + created := s.createKeyPair("default") + + list := s.listKeyPairs() + + s.Assert().Equal("default", created.Name) + s.Assert().Equal("ssh", created.Type) + s.Assert().NotEmpty(created.Fingerprint) + s.Require().Len(list, 1) + s.Assert().Equal("default", list[0].Name) +} + +func (s *KeyPairSuite) TestGetKeyPair() { + created := s.createKeyPair("default") + + found, err := keypairs.Get( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.Name, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal(created.Name, found.Name) + s.Assert().Equal(created.PublicKey, found.PublicKey) +} + +func (s *KeyPairSuite) TestDeleteKeyPair() { + created := s.createKeyPair("default") + + err := keypairs.Delete( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + created.Name, + nil, + ).ExtractErr() + s.Require().NoError(err) + + list := s.listKeyPairs() + + s.Assert().Empty(list) +} + +func (s *KeyPairSuite) listKeyPairs() []keypairs.KeyPair { + pages, err := keypairs.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + list, err := keypairs.ExtractKeyPairs(pages) + s.Require().NoError(err) + + return list +} + +func (s *KeyPairSuite) createKeyPair(name string) *keypairs.KeyPair { + created, err := keypairs.Create( + s.T().Context(), + testhelper.ServiceClient(s.server.URL+"/demo"), + keypairs.CreateOpts{ + Name: name, + PublicKey: "ssh-rsa test-public-key", + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + return created +} diff --git a/internal/api/compute/limits.go b/internal/api/compute/limits.go new file mode 100644 index 0000000..278745b --- /dev/null +++ b/internal/api/compute/limits.go @@ -0,0 +1,13 @@ +package compute + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) getLimits(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, limitsResponse{ + Limits: toLimitsDocument(h.service.GetLimits()), + }) +} diff --git a/internal/api/compute/limits_dto.go b/internal/api/compute/limits_dto.go new file mode 100644 index 0000000..63876d1 --- /dev/null +++ b/internal/api/compute/limits_dto.go @@ -0,0 +1,59 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type limitsResponse struct { + Limits limitsDocument `json:"limits"` +} + +type limitsDocument struct { + Absolute absoluteLimitsDocument `json:"absolute"` +} + +type absoluteLimitsDocument struct { + MaxTotalCores int `json:"maxTotalCores"` + MaxImageMeta int `json:"maxImageMeta"` + MaxServerMeta int `json:"maxServerMeta"` + MaxPersonality int `json:"maxPersonality"` + MaxPersonalitySize int `json:"maxPersonalitySize"` + MaxTotalKeypairs int `json:"maxTotalKeypairs"` + MaxSecurityGroups int `json:"maxSecurityGroups"` + MaxSecurityGroupRules int `json:"maxSecurityGroupRules"` + MaxServerGroups int `json:"maxServerGroups"` + MaxServerGroupMembers int `json:"maxServerGroupMembers"` + MaxTotalFloatingIps int `json:"maxTotalFloatingIps"` + MaxTotalInstances int `json:"maxTotalInstances"` + MaxTotalRAMSize int `json:"maxTotalRAMSize"` + TotalCoresUsed int `json:"totalCoresUsed"` + TotalInstancesUsed int `json:"totalInstancesUsed"` + TotalFloatingIpsUsed int `json:"totalFloatingIpsUsed"` + TotalRAMUsed int `json:"totalRAMUsed"` + TotalSecurityGroupsUsed int `json:"totalSecurityGroupsUsed"` + TotalServerGroupsUsed int `json:"totalServerGroupsUsed"` +} + +func toLimitsDocument(limits appcompute.Limits) limitsDocument { + return limitsDocument{ + Absolute: absoluteLimitsDocument{ + MaxTotalCores: limits.MaxTotalCores, + MaxImageMeta: limits.MaxImageMeta, + MaxServerMeta: limits.MaxServerMeta, + MaxPersonality: limits.MaxPersonality, + MaxPersonalitySize: limits.MaxPersonalitySize, + MaxTotalKeypairs: limits.MaxTotalKeypairs, + MaxSecurityGroups: limits.MaxSecurityGroups, + MaxSecurityGroupRules: limits.MaxSecurityGroupRules, + MaxServerGroups: limits.MaxServerGroups, + MaxServerGroupMembers: limits.MaxServerGroupMembers, + MaxTotalFloatingIps: limits.MaxTotalFloatingIps, + MaxTotalInstances: limits.MaxTotalInstances, + MaxTotalRAMSize: limits.MaxTotalRAMSize, + TotalCoresUsed: limits.TotalCoresUsed, + TotalInstancesUsed: limits.TotalInstancesUsed, + TotalFloatingIpsUsed: limits.TotalFloatingIpsUsed, + TotalRAMUsed: limits.TotalRAMUsed, + TotalSecurityGroupsUsed: limits.TotalSecurityGroupsUsed, + TotalServerGroupsUsed: limits.TotalServerGroupsUsed, + }, + } +} diff --git a/internal/api/compute/limits_test.go b/internal/api/compute/limits_test.go new file mode 100644 index 0000000..f9cf3ed --- /dev/null +++ b/internal/api/compute/limits_test.go @@ -0,0 +1,60 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/limits" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/stretchr/testify/suite" +) + +type LimitsSuite struct { + suite.Suite + server *httptest.Server +} + +func TestLimitsSuite(t *testing.T) { + suite.Run(t, new(LimitsSuite)) +} + +func (s *LimitsSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *LimitsSuite) TearDownTest() { + s.server.Close() +} + +func (s *LimitsSuite) TestGetLimits() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := servers.Create( + s.T().Context(), + client, + servers.CreateOpts{ + Name: "test-server", + ImageRef: "img-1", + FlavorRef: "1", + }, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + result, err := limits.Get( + s.T().Context(), + client, + nil, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(result) + + s.Assert().Equal(100, result.Absolute.MaxTotalInstances) + s.Assert().Equal(1, result.Absolute.TotalInstancesUsed) + s.Assert().Equal(1, result.Absolute.TotalCoresUsed) + s.Assert().Equal(2048, result.Absolute.TotalRAMUsed) +} diff --git a/internal/api/compute/metadata.go b/internal/api/compute/metadata.go new file mode 100644 index 0000000..284b892 --- /dev/null +++ b/internal/api/compute/metadata.go @@ -0,0 +1,147 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) getServerMetadata(w http.ResponseWriter, r *http.Request) { + metadata, err := h.service.ServerMetadata(chi.URLParam(r, "server_id")) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata lookup failed") + return + } + + respond.JSON(w, http.StatusOK, metadataResponse{ + Metadata: metadata, + }) +} + +func (h Handler) resetServerMetadata(w http.ResponseWriter, r *http.Request) { + var request metadataRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + metadata, err := h.service.ResetServerMetadata( + chi.URLParam(r, "server_id"), + request.Metadata, + ) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata reset failed") + return + } + + respond.JSON(w, http.StatusOK, metadataResponse{ + Metadata: metadata, + }) +} + +func (h Handler) updateServerMetadata(w http.ResponseWriter, r *http.Request) { + var request metadataRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + metadata, err := h.service.UpdateServerMetadata( + chi.URLParam(r, "server_id"), + request.Metadata, + ) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata update failed") + return + } + + respond.JSON(w, http.StatusOK, metadataResponse{ + Metadata: metadata, + }) +} + +func (h Handler) getServerMetadatum(w http.ResponseWriter, r *http.Request) { + meta, err := h.service.ServerMetadatum( + chi.URLParam(r, "server_id"), + chi.URLParam(r, "key"), + ) + if errors.Is(err, appcompute.ErrServerNotFound) || + errors.Is(err, appcompute.ErrMetadatumNotFound) { + respond.Error(w, http.StatusNotFound, "metadata not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata lookup failed") + return + } + + respond.JSON(w, http.StatusOK, metadatumResponse{ + Meta: meta, + }) +} + +func (h Handler) setServerMetadatum(w http.ResponseWriter, r *http.Request) { + var request metadatumRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid JSON request body") + return + } + + key := chi.URLParam(r, "key") + value, ok := request.Meta[key] + if !ok { + respond.Error(w, http.StatusBadRequest, "metadata key mismatch") + return + } + + meta, err := h.service.SetServerMetadatum( + chi.URLParam(r, "server_id"), + key, + value, + ) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata update failed") + return + } + + respond.JSON(w, http.StatusOK, metadatumResponse{ + Meta: meta, + }) +} + +func (h Handler) deleteServerMetadatum(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteServerMetadatum( + chi.URLParam(r, "server_id"), + chi.URLParam(r, "key"), + ) + if errors.Is(err, appcompute.ErrServerNotFound) { + respond.Error(w, http.StatusNotFound, "server not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "metadata delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/compute/metadata_dto.go b/internal/api/compute/metadata_dto.go new file mode 100644 index 0000000..47973ad --- /dev/null +++ b/internal/api/compute/metadata_dto.go @@ -0,0 +1,17 @@ +package compute + +type metadataRequest struct { + Metadata map[string]string `json:"metadata"` +} + +type metadataResponse struct { + Metadata map[string]string `json:"metadata"` +} + +type metadatumRequest struct { + Meta map[string]string `json:"meta"` +} + +type metadatumResponse struct { + Meta map[string]string `json:"meta"` +} diff --git a/internal/api/compute/metadata_test.go b/internal/api/compute/metadata_test.go new file mode 100644 index 0000000..4ebaf3c --- /dev/null +++ b/internal/api/compute/metadata_test.go @@ -0,0 +1,138 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/JSYoo5B/SandStack/internal/platform/clock" + "github.com/JSYoo5B/SandStack/internal/platform/idgen" + storecompute "github.com/JSYoo5B/SandStack/internal/store/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/stretchr/testify/suite" +) + +type MetadataSuite struct { + suite.Suite + server *httptest.Server + service *appcompute.Service +} + +func TestMetadataSuite(t *testing.T) { + suite.Run(t, new(MetadataSuite)) +} + +func (s *MetadataSuite) SetupTest() { + s.service = appcompute.NewServiceWithRuntime( + storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), + clock.Wall(), + idgen.Random(), + ) + s.server = httptest.NewServer( + compute.NewRouterWithService( + testhelper.DefaultConfig(), + s.service, + ), + ) +} + +func (s *MetadataSuite) TearDownTest() { + s.server.Close() +} + +func (s *MetadataSuite) TestServerMetadataLifecycle() { + created := s.service.CreateServer(appcompute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + Metadata: map[string]string{ + "role": "web", + }, + }) + client := testhelper.ServiceClient(s.server.URL + "/demo") + + initial, err := servers.Metadata( + s.T().Context(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + + reset, err := servers.ResetMetadata( + s.T().Context(), + client, + created.ID, + servers.MetadataOpts{"role": "api"}, + ).Extract() + s.Require().NoError(err) + + updated, err := servers.UpdateMetadata( + s.T().Context(), + client, + created.ID, + servers.MetadataOpts{"owner": "qa"}, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal(map[string]string{"role": "web"}, initial) + s.Assert().Equal(map[string]string{"role": "api"}, reset) + s.Assert().Equal( + map[string]string{ + "role": "api", + "owner": "qa", + }, + updated, + ) +} + +func (s *MetadataSuite) TestServerMetadatumLifecycle() { + created := s.service.CreateServer(appcompute.CreateServer{ + Name: "web", + ImageID: "img-1", + FlavorID: "1", + Metadata: map[string]string{ + "role": "web", + }, + }) + client := testhelper.ServiceClient(s.server.URL + "/demo") + + initial, err := servers.Metadatum( + s.T().Context(), + client, + created.ID, + "role", + ).Extract() + s.Require().NoError(err) + + updated, err := servers.CreateMetadatum( + s.T().Context(), + client, + created.ID, + servers.MetadatumOpts{"role": "api"}, + ).Extract() + s.Require().NoError(err) + + err = servers.DeleteMetadatum( + s.T().Context(), + client, + created.ID, + "role", + ).ExtractErr() + s.Require().NoError(err) + + final, err := servers.Metadata( + s.T().Context(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + + s.Assert().Equal(map[string]string{"role": "web"}, initial) + s.Assert().Equal(map[string]string{"role": "api"}, updated) + s.Assert().Empty(final) +} diff --git a/internal/api/compute/router.go b/internal/api/compute/router.go index 59834b2..ae13186 100644 --- a/internal/api/compute/router.go +++ b/internal/api/compute/router.go @@ -32,6 +32,9 @@ func NewHandler(cfg config.Config) Handler { cfg, appcompute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Wall(), idgen.Random(), ), @@ -52,14 +55,47 @@ func (h Handler) Router() http.Handler { router := chi.NewRouter() router.Get("/{project_id}", h.version) router.Get("/{project_id}/", h.version) + router.Get("/{project_id}/limits", h.getLimits) + router.Get("/{project_id}/os-availability-zone", h.listAvailabilityZones) + router.Get("/{project_id}/os-availability-zone/detail", h.listAvailabilityZones) + router.Get("/{project_id}/os-services", h.listComputeServices) + router.Get("/{project_id}/os-hypervisors/detail", h.listHypervisors) + router.Get("/{project_id}/os-hypervisors/statistics", h.getHypervisorStatistics) + router.Get("/{project_id}/os-hypervisors/{hypervisor_id}", h.getHypervisor) + router.Get("/{project_id}/os-aggregates", h.listAggregates) + router.Post("/{project_id}/os-aggregates", h.createAggregate) + router.Get("/{project_id}/os-aggregates/{aggregate_id}", h.getAggregate) + router.Delete("/{project_id}/os-aggregates/{aggregate_id}", h.deleteAggregate) router.Get("/{project_id}/flavors", h.listFlavors) router.Get("/{project_id}/flavors/detail", h.listFlavors) router.Get("/{project_id}/flavors/{flavor_id}", h.getFlavor) + router.Get("/{project_id}/flavors/{flavor_id}/os-extra_specs", h.listFlavorExtraSpecs) + router.Post("/{project_id}/flavors/{flavor_id}/os-extra_specs", h.createFlavorExtraSpecs) + router.Get("/{project_id}/flavors/{flavor_id}/os-extra_specs/{key}", h.getFlavorExtraSpec) + router.Put("/{project_id}/flavors/{flavor_id}/os-extra_specs/{key}", h.updateFlavorExtraSpec) + router.Delete("/{project_id}/flavors/{flavor_id}/os-extra_specs/{key}", h.deleteFlavorExtraSpec) router.Get("/{project_id}/servers", h.listServers) router.Post("/{project_id}/servers", h.createServer) router.Get("/{project_id}/servers/detail", h.listServers) router.Get("/{project_id}/servers/{server_id}", h.getServer) router.Delete("/{project_id}/servers/{server_id}", h.deleteServer) + router.Get("/{project_id}/servers/{server_id}/ips", h.listServerAddresses) + router.Get("/{project_id}/servers/{server_id}/ips/{network}", h.listServerAddressesByNetwork) + router.Post("/{project_id}/servers/{server_id}/action", h.actionServer) + router.Get("/{project_id}/servers/{server_id}/metadata", h.getServerMetadata) + router.Put("/{project_id}/servers/{server_id}/metadata", h.resetServerMetadata) + router.Post("/{project_id}/servers/{server_id}/metadata", h.updateServerMetadata) + router.Get("/{project_id}/servers/{server_id}/metadata/{key}", h.getServerMetadatum) + router.Put("/{project_id}/servers/{server_id}/metadata/{key}", h.setServerMetadatum) + router.Delete("/{project_id}/servers/{server_id}/metadata/{key}", h.deleteServerMetadatum) + router.Get("/{project_id}/os-keypairs", h.listKeyPairs) + router.Post("/{project_id}/os-keypairs", h.createKeyPair) + router.Get("/{project_id}/os-keypairs/{keypair_name}", h.getKeyPair) + router.Delete("/{project_id}/os-keypairs/{keypair_name}", h.deleteKeyPair) + router.Get("/{project_id}/os-server-groups", h.listServerGroups) + router.Post("/{project_id}/os-server-groups", h.createServerGroup) + router.Get("/{project_id}/os-server-groups/{group_id}", h.getServerGroup) + router.Delete("/{project_id}/os-server-groups/{group_id}", h.deleteServerGroup) return router } diff --git a/internal/api/compute/server_group.go b/internal/api/compute/server_group.go new file mode 100644 index 0000000..a45a83d --- /dev/null +++ b/internal/api/compute/server_group.go @@ -0,0 +1,61 @@ +package compute + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + "github.com/go-chi/chi/v5" +) + +func (h Handler) listServerGroups(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, serverGroupListResponse{ + ServerGroups: toServerGroupDocuments(h.service.ListServerGroups()), + }) +} + +func (h Handler) createServerGroup(w http.ResponseWriter, r *http.Request) { + var request createServerGroupRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + respond.Error(w, http.StatusBadRequest, "invalid server group request") + return + } + + group := h.service.CreateServerGroup(request.createServerGroup()) + + respond.JSON(w, http.StatusOK, serverGroupResponse{ + ServerGroup: toServerGroupDocument(group), + }) +} + +func (h Handler) getServerGroup(w http.ResponseWriter, r *http.Request) { + group, err := h.service.GetServerGroup(chi.URLParam(r, "group_id")) + if errors.Is(err, appcompute.ErrServerGroupNotFound) { + respond.Error(w, http.StatusNotFound, "server group not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server group lookup failed") + return + } + + respond.JSON(w, http.StatusOK, serverGroupResponse{ + ServerGroup: toServerGroupDocument(group), + }) +} + +func (h Handler) deleteServerGroup(w http.ResponseWriter, r *http.Request) { + err := h.service.DeleteServerGroup(chi.URLParam(r, "group_id")) + if errors.Is(err, appcompute.ErrServerGroupNotFound) { + respond.Error(w, http.StatusNotFound, "server group not found") + return + } + if err != nil { + respond.Error(w, http.StatusInternalServerError, "server group delete failed") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/compute/server_group_dto.go b/internal/api/compute/server_group_dto.go new file mode 100644 index 0000000..f1eae89 --- /dev/null +++ b/internal/api/compute/server_group_dto.go @@ -0,0 +1,69 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type createServerGroupRequest struct { + ServerGroup createServerGroupDocument `json:"server_group"` +} + +type createServerGroupDocument struct { + Name string `json:"name"` + Policies []string `json:"policies"` + Policy string `json:"policy"` +} + +type serverGroupListResponse struct { + ServerGroups []serverGroupDocument `json:"server_groups"` +} + +type serverGroupResponse struct { + ServerGroup serverGroupDocument `json:"server_group"` +} + +type serverGroupDocument struct { + ID string `json:"id"` + Name string `json:"name"` + Policies []string `json:"policies"` + Members []string `json:"members"` + UserID string `json:"user_id"` + ProjectID string `json:"project_id"` + Metadata map[string]any `json:"metadata"` + Policy *string `json:"policy,omitempty"` +} + +func (request createServerGroupRequest) createServerGroup() appcompute.CreateServerGroup { + return appcompute.CreateServerGroup{ + Name: request.ServerGroup.Name, + Policies: request.ServerGroup.Policies, + Policy: request.ServerGroup.Policy, + } +} + +func toServerGroupDocuments( + groups []appcompute.ServerGroup, +) []serverGroupDocument { + documents := make([]serverGroupDocument, 0, len(groups)) + for _, group := range groups { + documents = append(documents, toServerGroupDocument(group)) + } + + return documents +} + +func toServerGroupDocument(group appcompute.ServerGroup) serverGroupDocument { + var policy *string + if group.Policy != "" { + policy = &group.Policy + } + + return serverGroupDocument{ + ID: group.ID, + Name: group.Name, + Policies: group.Policies, + Members: group.Members, + UserID: group.UserID, + ProjectID: group.ProjectID, + Metadata: group.Metadata, + Policy: policy, + } +} diff --git a/internal/api/compute/server_group_test.go b/internal/api/compute/server_group_test.go new file mode 100644 index 0000000..9566f24 --- /dev/null +++ b/internal/api/compute/server_group_test.go @@ -0,0 +1,72 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servergroups" + "github.com/stretchr/testify/suite" +) + +type ServerGroupSuite struct { + suite.Suite + server *httptest.Server +} + +func TestServerGroupSuite(t *testing.T) { + suite.Run(t, new(ServerGroupSuite)) +} + +func (s *ServerGroupSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ServerGroupSuite) TearDownTest() { + s.server.Close() +} + +func (s *ServerGroupSuite) TestCreateListGetAndDeleteServerGroup() { + client := testhelper.ServiceClient(s.server.URL + "/demo") + created, err := servergroups.Create( + s.T().Context(), + client, + servergroups.CreateOpts{ + Name: "web-group", + Policies: []string{"anti-affinity"}, + }, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(created) + + pages, err := servergroups.List( + client, + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + listed, err := servergroups.ExtractServerGroups(pages) + s.Require().NoError(err) + found, err := servergroups.Get( + s.T().Context(), + client, + created.ID, + ).Extract() + s.Require().NoError(err) + s.Require().NotNil(found) + + s.Assert().Equal("web-group", created.Name) + s.Assert().Equal([]string{"anti-affinity"}, created.Policies) + s.Require().Len(listed, 1) + s.Assert().Equal(created.ID, listed[0].ID) + s.Assert().Equal(created.ID, found.ID) + + err = servergroups.Delete( + s.T().Context(), + client, + created.ID, + ).ExtractErr() + s.Require().NoError(err) +} diff --git a/internal/api/compute/service.go b/internal/api/compute/service.go new file mode 100644 index 0000000..c193ea2 --- /dev/null +++ b/internal/api/compute/service.go @@ -0,0 +1,13 @@ +package compute + +import ( + "net/http" + + "github.com/JSYoo5B/SandStack/internal/api/respond" +) + +func (h Handler) listComputeServices(w http.ResponseWriter, _ *http.Request) { + respond.JSON(w, http.StatusOK, computeServiceListResponse{ + Services: toComputeServiceDocuments(h.service.ListComputeServices()), + }) +} diff --git a/internal/api/compute/service_dto.go b/internal/api/compute/service_dto.go new file mode 100644 index 0000000..83f5b81 --- /dev/null +++ b/internal/api/compute/service_dto.go @@ -0,0 +1,46 @@ +package compute + +import appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" + +type computeServiceListResponse struct { + Services []computeServiceDocument `json:"services"` +} + +type computeServiceDocument struct { + ID string `json:"id"` + Binary string `json:"binary"` + DisabledReason string `json:"disabled_reason"` + ForcedDown bool `json:"forced_down"` + Host string `json:"host"` + State string `json:"state"` + Status string `json:"status"` + UpdatedAt string `json:"updated_at"` + Zone string `json:"zone"` +} + +func toComputeServiceDocuments( + services []appcompute.ComputeService, +) []computeServiceDocument { + documents := make([]computeServiceDocument, 0, len(services)) + for _, service := range services { + documents = append(documents, toComputeServiceDocument(service)) + } + + return documents +} + +func toComputeServiceDocument( + service appcompute.ComputeService, +) computeServiceDocument { + return computeServiceDocument{ + ID: service.ID, + Binary: service.Binary, + DisabledReason: service.DisabledReason, + ForcedDown: service.ForcedDown, + Host: service.Host, + State: service.State, + Status: service.Status, + UpdatedAt: service.UpdatedAt, + Zone: service.Zone, + } +} diff --git a/internal/api/compute/service_test.go b/internal/api/compute/service_test.go new file mode 100644 index 0000000..603a853 --- /dev/null +++ b/internal/api/compute/service_test.go @@ -0,0 +1,47 @@ +package compute_test + +import ( + "net/http/httptest" + "testing" + + "github.com/JSYoo5B/SandStack/internal/api/compute" + "github.com/JSYoo5B/SandStack/internal/testhelper" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/services" + "github.com/stretchr/testify/suite" +) + +type ServiceSuite struct { + suite.Suite + server *httptest.Server +} + +func TestServiceSuite(t *testing.T) { + suite.Run(t, new(ServiceSuite)) +} + +func (s *ServiceSuite) SetupTest() { + s.server = httptest.NewServer( + compute.NewRouter(testhelper.DefaultConfig()), + ) +} + +func (s *ServiceSuite) TearDownTest() { + s.server.Close() +} + +func (s *ServiceSuite) TestListComputeServices() { + pages, err := services.List( + testhelper.ServiceClient(s.server.URL+"/demo"), + nil, + ).AllPages(s.T().Context()) + s.Require().NoError(err) + + listed, err := services.ExtractServices(pages) + s.Require().NoError(err) + + s.Require().Len(listed, 2) + s.Assert().Equal("compute-service-1", listed[0].ID) + s.Assert().Equal("nova-compute", listed[0].Binary) + s.Assert().Equal("up", listed[0].State) + s.Assert().Equal("enabled", listed[0].Status) +} diff --git a/internal/api/router.go b/internal/api/router.go index 0a45b10..541c274 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -46,6 +46,9 @@ func NewRouter(cfg config.Config) http.Handler { identityHandler := identity.NewHandlerWithService(cfg, identityService) computeService := appcompute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Wall(), idgen.Random(), ) diff --git a/internal/app/compute/action.go b/internal/app/compute/action.go new file mode 100644 index 0000000..c0a5b87 --- /dev/null +++ b/internal/app/compute/action.go @@ -0,0 +1,47 @@ +package compute + +func (s *Service) StartServer(id string) error { + server, err := s.serverRepository.Get(id) + if err != nil { + return err + } + + server.Status = "ACTIVE" + server.Progress = 100 + _, err = s.serverRepository.Update(server) + return err +} + +func (s *Service) StopServer(id string) error { + server, err := s.serverRepository.Get(id) + if err != nil { + return err + } + + server.Status = "SHUTOFF" + server.Progress = 0 + _, err = s.serverRepository.Update(server) + return err +} + +func (s *Service) RebootServer(id string) error { + server, err := s.serverRepository.Get(id) + if err != nil { + return err + } + + server.Status = "ACTIVE" + server.Progress = 100 + _, err = s.serverRepository.Update(server) + return err +} + +func (s *Service) AddServerSecurityGroup(id string, _ string) error { + _, err := s.serverRepository.Get(id) + return err +} + +func (s *Service) RemoveServerSecurityGroup(id string, _ string) error { + _, err := s.serverRepository.Get(id) + return err +} diff --git a/internal/app/compute/address.go b/internal/app/compute/address.go new file mode 100644 index 0000000..2f497b9 --- /dev/null +++ b/internal/app/compute/address.go @@ -0,0 +1,28 @@ +package compute + +func (s *Service) ServerAddresses(id string) (map[string][]ServerAddress, error) { + if _, err := s.serverRepository.Get(id); err != nil { + return nil, err + } + + return map[string][]ServerAddress{ + "private": { + { + Version: 4, + Address: "10.0.0.10", + }, + }, + }, nil +} + +func (s *Service) ServerAddressesByNetwork( + id string, + network string, +) ([]ServerAddress, error) { + addresses, err := s.ServerAddresses(id) + if err != nil { + return nil, err + } + + return addresses[network], nil +} diff --git a/internal/app/compute/aggregate.go b/internal/app/compute/aggregate.go new file mode 100644 index 0000000..c68a70b --- /dev/null +++ b/internal/app/compute/aggregate.go @@ -0,0 +1,35 @@ +package compute + +import "errors" + +var ErrAggregateNotFound = errors.New("aggregate not found") + +const aggregateTimestampFormat = "2006-01-02T15:04:05.000000" + +func (s *Service) CreateAggregate(input CreateAggregate) Aggregate { + now := s.clock.Now().UTC().Format(aggregateTimestampFormat) + id := len(s.aggregateRepository.List()) + 1 + + return s.aggregateRepository.Create(Aggregate{ + ID: id, + Name: input.Name, + AvailabilityZone: input.AvailabilityZone, + Hosts: []string{}, + Metadata: map[string]string{}, + CreatedAt: now, + UpdatedAt: now, + UUID: "aggregate-" + s.idGen.Hex(16), + }) +} + +func (s *Service) ListAggregates() []Aggregate { + return s.aggregateRepository.List() +} + +func (s *Service) GetAggregate(id int) (Aggregate, error) { + return s.aggregateRepository.Get(id) +} + +func (s *Service) DeleteAggregate(id int) error { + return s.aggregateRepository.Delete(id) +} diff --git a/internal/app/compute/availability_zone.go b/internal/app/compute/availability_zone.go new file mode 100644 index 0000000..6071625 --- /dev/null +++ b/internal/app/compute/availability_zone.go @@ -0,0 +1,10 @@ +package compute + +func (s *Service) ListAvailabilityZones() []AvailabilityZone { + return []AvailabilityZone{ + { + Name: "nova", + Available: true, + }, + } +} diff --git a/internal/app/compute/compute_service.go b/internal/app/compute/compute_service.go new file mode 100644 index 0000000..80fb1d4 --- /dev/null +++ b/internal/app/compute/compute_service.go @@ -0,0 +1,24 @@ +package compute + +func (s *Service) ListComputeServices() []ComputeService { + return []ComputeService{ + { + ID: "compute-service-1", + Binary: "nova-compute", + Host: "sandstack", + State: "up", + Status: "enabled", + UpdatedAt: "2026-07-03T00:00:00.000000", + Zone: "nova", + }, + { + ID: "scheduler-service-1", + Binary: "nova-scheduler", + Host: "sandstack", + State: "up", + Status: "enabled", + UpdatedAt: "2026-07-03T00:00:00.000000", + Zone: "internal", + }, + } +} diff --git a/internal/app/compute/flavor.go b/internal/app/compute/flavor.go index e024ab0..df0b3e7 100644 --- a/internal/app/compute/flavor.go +++ b/internal/app/compute/flavor.go @@ -20,3 +20,98 @@ func (s *Service) GetFlavor(id string) (Flavor, error) { return Flavor{}, ErrFlavorNotFound } + +func (s *Service) ListFlavorExtraSpecs(id string) (map[string]string, error) { + flavor, err := s.GetFlavor(id) + if err != nil { + return nil, err + } + + return copyStringMap(flavor.ExtraSpecs), nil +} + +func (s *Service) GetFlavorExtraSpec( + id string, + key string, +) (map[string]string, error) { + extraSpecs, err := s.ListFlavorExtraSpecs(id) + if err != nil { + return nil, err + } + + value, ok := extraSpecs[key] + if !ok { + return nil, ErrFlavorNotFound + } + + return map[string]string{key: value}, nil +} + +func (s *Service) CreateFlavorExtraSpecs( + id string, + extraSpecs map[string]string, +) (map[string]string, error) { + index, err := s.findFlavorIndex(id) + if err != nil { + return nil, err + } + if s.flavors[index].ExtraSpecs == nil { + s.flavors[index].ExtraSpecs = map[string]string{} + } + for key, value := range extraSpecs { + s.flavors[index].ExtraSpecs[key] = value + } + + return copyStringMap(s.flavors[index].ExtraSpecs), nil +} + +func (s *Service) UpdateFlavorExtraSpec( + id string, + key string, + extraSpecs map[string]string, +) (map[string]string, error) { + value, ok := extraSpecs[key] + if !ok { + return nil, ErrFlavorNotFound + } + + _, err := s.CreateFlavorExtraSpecs( + id, + map[string]string{key: value}, + ) + if err != nil { + return nil, err + } + + return map[string]string{key: value}, nil +} + +func (s *Service) DeleteFlavorExtraSpec(id string, key string) error { + index, err := s.findFlavorIndex(id) + if err != nil { + return err + } + + delete(s.flavors[index].ExtraSpecs, key) + + return nil +} + +func (s *Service) findFlavorIndex(id string) (int, error) { + for index, flavor := range s.flavors { + if flavor.ID == id { + return index, nil + } + } + + return 0, ErrFlavorNotFound +} + +func copyStringMap(values map[string]string) map[string]string { + copied := make(map[string]string, len(values)) + for key, value := range values { + copied[key] = value + } + + return copied +} diff --git a/internal/app/compute/hypervisor.go b/internal/app/compute/hypervisor.go new file mode 100644 index 0000000..b75e39e --- /dev/null +++ b/internal/app/compute/hypervisor.go @@ -0,0 +1,39 @@ +package compute + +import "errors" + +var ErrHypervisorNotFound = errors.New("hypervisor not found") + +func (s *Service) ListHypervisors() []Hypervisor { + return []Hypervisor{s.defaultHypervisor()} +} + +func (s *Service) GetHypervisor(id string) (Hypervisor, error) { + hypervisor := s.defaultHypervisor() + if hypervisor.ID != id { + return Hypervisor{}, ErrHypervisorNotFound + } + + return hypervisor, nil +} + +func (s *Service) defaultHypervisor() Hypervisor { + return Hypervisor{ + ID: "1", + Status: "enabled", + State: "up", + DiskAvailableLeast: 80, + HostIP: "127.0.0.1", + FreeDiskGB: 80, + FreeRAMMB: 512000, + Hostname: "sandstack", + Type: "sandstack", + Version: 1, + LocalGB: 100, + LocalGBUsed: 20, + MemoryMB: 512000, + MemoryMBUsed: 0, + ServiceID: "compute-service-1", + VCPUs: 200, + } +} diff --git a/internal/app/compute/keypair.go b/internal/app/compute/keypair.go new file mode 100644 index 0000000..b61a5aa --- /dev/null +++ b/internal/app/compute/keypair.go @@ -0,0 +1,42 @@ +package compute + +import "errors" + +var ErrKeyPairNotFound = errors.New("key pair not found") + +func (s *Service) CreateKeyPair(input CreateKeyPair) KeyPair { + keyType := input.Type + if keyType == "" { + keyType = "ssh" + } + + publicKey := input.PublicKey + privateKey := "" + if publicKey == "" { + publicKey = "ssh-rsa sandstack-generated-key" + privateKey = "-----BEGIN PRIVATE KEY-----\nsandstack\n-----END PRIVATE KEY-----" + } + + keyPair := KeyPair{ + Name: input.Name, + Fingerprint: "sandstack:" + s.idGen.Hex(8), + PublicKey: publicKey, + PrivateKey: privateKey, + UserID: input.UserID, + Type: keyType, + } + + return s.keyPairRepository.Create(keyPair) +} + +func (s *Service) ListKeyPairs() []KeyPair { + return s.keyPairRepository.List() +} + +func (s *Service) GetKeyPair(name string) (KeyPair, error) { + return s.keyPairRepository.Get(name) +} + +func (s *Service) DeleteKeyPair(name string) error { + return s.keyPairRepository.Delete(name) +} diff --git a/internal/app/compute/limits.go b/internal/app/compute/limits.go new file mode 100644 index 0000000..9e61409 --- /dev/null +++ b/internal/app/compute/limits.go @@ -0,0 +1,67 @@ +package compute + +const ( + defaultMaxTotalCores = 200 + defaultMaxImageMeta = 128 + defaultMaxServerMeta = 128 + defaultMaxPersonality = 5 + defaultMaxPersonalitySize = 10240 + defaultMaxTotalKeypairs = 100 + defaultMaxSecurityGroups = 10 + defaultMaxSecurityGroupRules = 20 + defaultMaxServerGroups = 10 + defaultMaxServerGroupMembers = 10 + defaultMaxTotalFloatingIps = 50 + defaultMaxTotalInstances = 100 + defaultMaxTotalRAMSize = 512000 +) + +func (s *Service) GetLimits() Limits { + servers := s.serverRepository.List() + + return Limits{ + MaxTotalCores: defaultMaxTotalCores, + MaxImageMeta: defaultMaxImageMeta, + MaxServerMeta: defaultMaxServerMeta, + MaxPersonality: defaultMaxPersonality, + MaxPersonalitySize: defaultMaxPersonalitySize, + MaxTotalKeypairs: defaultMaxTotalKeypairs, + MaxSecurityGroups: defaultMaxSecurityGroups, + MaxSecurityGroupRules: defaultMaxSecurityGroupRules, + MaxServerGroups: defaultMaxServerGroups, + MaxServerGroupMembers: defaultMaxServerGroupMembers, + MaxTotalFloatingIps: defaultMaxTotalFloatingIps, + MaxTotalInstances: defaultMaxTotalInstances, + MaxTotalRAMSize: defaultMaxTotalRAMSize, + TotalCoresUsed: s.totalServerCores(servers), + TotalInstancesUsed: len(servers), + TotalFloatingIpsUsed: 0, + TotalRAMUsed: s.totalServerRAM(servers), + TotalSecurityGroupsUsed: 0, + TotalServerGroupsUsed: 0, + } +} + +func (s *Service) totalServerCores(servers []Server) int { + total := 0 + for _, server := range servers { + flavor, err := s.GetFlavor(server.FlavorID) + if err == nil { + total += flavor.VCPUs + } + } + + return total +} + +func (s *Service) totalServerRAM(servers []Server) int { + total := 0 + for _, server := range servers { + flavor, err := s.GetFlavor(server.FlavorID) + if err == nil { + total += flavor.RAM + } + } + + return total +} diff --git a/internal/app/compute/metadata.go b/internal/app/compute/metadata.go new file mode 100644 index 0000000..3bb5c80 --- /dev/null +++ b/internal/app/compute/metadata.go @@ -0,0 +1,98 @@ +package compute + +import "errors" + +var ErrMetadatumNotFound = errors.New("server metadatum not found") + +func (s *Service) ServerMetadata(id string) (map[string]string, error) { + server, err := s.serverRepository.Get(id) + if err != nil { + return nil, err + } + + return copyMetadata(server.Metadata), nil +} + +func (s *Service) ResetServerMetadata( + id string, + metadata map[string]string, +) (map[string]string, error) { + server, err := s.serverRepository.Get(id) + if err != nil { + return nil, err + } + + server.Metadata = copyMetadata(metadata) + if _, err := s.serverRepository.Update(server); err != nil { + return nil, err + } + + return copyMetadata(server.Metadata), nil +} + +func (s *Service) UpdateServerMetadata( + id string, + metadata map[string]string, +) (map[string]string, error) { + server, err := s.serverRepository.Get(id) + if err != nil { + return nil, err + } + + if server.Metadata == nil { + server.Metadata = map[string]string{} + } + for key, value := range metadata { + server.Metadata[key] = value + } + if _, err := s.serverRepository.Update(server); err != nil { + return nil, err + } + + return copyMetadata(server.Metadata), nil +} + +func (s *Service) ServerMetadatum( + id string, + key string, +) (map[string]string, error) { + metadata, err := s.ServerMetadata(id) + if err != nil { + return nil, err + } + + value, ok := metadata[key] + if !ok { + return nil, ErrMetadatumNotFound + } + + return map[string]string{key: value}, nil +} + +func (s *Service) SetServerMetadatum( + id string, + key string, + value string, +) (map[string]string, error) { + return s.UpdateServerMetadata(id, map[string]string{key: value}) +} + +func (s *Service) DeleteServerMetadatum(id string, key string) error { + server, err := s.serverRepository.Get(id) + if err != nil { + return err + } + + delete(server.Metadata, key) + _, err = s.serverRepository.Update(server) + return err +} + +func copyMetadata(metadata map[string]string) map[string]string { + copied := make(map[string]string, len(metadata)) + for key, value := range metadata { + copied[key] = value + } + + return copied +} diff --git a/internal/app/compute/repository.go b/internal/app/compute/repository.go index 574c4c4..d4a7d08 100644 --- a/internal/app/compute/repository.go +++ b/internal/app/compute/repository.go @@ -8,3 +8,27 @@ type ServerRepository interface { Delete(id string) error Reset() } + +type KeyPairRepository interface { + Create(keyPair KeyPair) KeyPair + List() []KeyPair + Get(name string) (KeyPair, error) + Delete(name string) error + Reset() +} + +type ServerGroupRepository interface { + Create(serverGroup ServerGroup) ServerGroup + List() []ServerGroup + Get(id string) (ServerGroup, error) + Delete(id string) error + Reset() +} + +type AggregateRepository interface { + Create(aggregate Aggregate) Aggregate + List() []Aggregate + Get(id int) (Aggregate, error) + Delete(id int) error + Reset() +} diff --git a/internal/app/compute/server_group.go b/internal/app/compute/server_group.go new file mode 100644 index 0000000..0a22d0b --- /dev/null +++ b/internal/app/compute/server_group.go @@ -0,0 +1,39 @@ +package compute + +import "errors" + +var ErrServerGroupNotFound = errors.New("server group not found") + +func (s *Service) CreateServerGroup(input CreateServerGroup) ServerGroup { + policies := input.Policies + policy := input.Policy + if len(policies) == 0 && policy != "" { + policies = []string{policy} + } + if policy == "" && len(policies) > 0 { + policy = policies[0] + } + + return s.groupRepository.Create(ServerGroup{ + ID: "sg-" + s.idGen.Hex(16), + Name: input.Name, + Policies: policies, + Members: []string{}, + UserID: "admin", + ProjectID: "demo", + Metadata: map[string]any{}, + Policy: policy, + }) +} + +func (s *Service) ListServerGroups() []ServerGroup { + return s.groupRepository.List() +} + +func (s *Service) GetServerGroup(id string) (ServerGroup, error) { + return s.groupRepository.Get(id) +} + +func (s *Service) DeleteServerGroup(id string) error { + return s.groupRepository.Delete(id) +} diff --git a/internal/app/compute/server_test.go b/internal/app/compute/server_test.go index 449f25b..55003a5 100644 --- a/internal/app/compute/server_test.go +++ b/internal/app/compute/server_test.go @@ -23,6 +23,9 @@ func (s *ServerSuite) TestCreateServerUsesInjectedClock() { now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) service := compute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Fixed(now), idgen.Random(), ) @@ -40,6 +43,9 @@ func (s *ServerSuite) TestCreateServerUsesInjectedClock() { func (s *ServerSuite) TestCreateServerUsesInjectedIDGenerator() { service := compute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Fixed(time.Time{}), idgen.Fixed("server-id"), ) @@ -57,6 +63,9 @@ func (s *ServerSuite) TestGetServerActivatesCreatedServer() { now := time.Date(2026, 6, 23, 8, 30, 0, 0, time.UTC) service := compute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Fixed(now), idgen.Fixed("server-id"), ) @@ -78,6 +87,9 @@ func (s *ServerSuite) TestGetServerActivatesCreatedServer() { func (s *ServerSuite) TestResetClearsServers() { service := compute.NewServiceWithRuntime( storecompute.NewMemoryServerRepository(), + storecompute.NewMemoryKeyPairRepository(), + storecompute.NewMemoryServerGroupRepository(), + storecompute.NewMemoryAggregateRepository(), clock.Fixed(time.Time{}), idgen.Fixed("server-id"), ) @@ -86,8 +98,10 @@ func (s *ServerSuite) TestResetClearsServers() { ImageID: "img-1", FlavorID: "1", }) + service.CreateKeyPair(compute.CreateKeyPair{Name: "default"}) service.Reset() s.Assert().Empty(service.ListServers()) + s.Assert().Empty(service.ListKeyPairs()) } diff --git a/internal/app/compute/service.go b/internal/app/compute/service.go index fbf2c18..c922b26 100644 --- a/internal/app/compute/service.go +++ b/internal/app/compute/service.go @@ -6,14 +6,20 @@ import ( ) type Service struct { - flavors []Flavor - serverRepository ServerRepository - clock clock.Clock - idGen idgen.Generator + flavors []Flavor + serverRepository ServerRepository + keyPairRepository KeyPairRepository + groupRepository ServerGroupRepository + aggregateRepository AggregateRepository + clock clock.Clock + idGen idgen.Generator } func NewServiceWithRuntime( serverRepository ServerRepository, + keyPairRepository KeyPairRepository, + groupRepository ServerGroupRepository, + aggregateRepository AggregateRepository, clock clock.Clock, idGen idgen.Generator, ) *Service { @@ -33,12 +39,18 @@ func NewServiceWithRuntime( ExtraSpecs: map[string]string{}, }, }, - serverRepository: serverRepository, - clock: clock, - idGen: idGen, + serverRepository: serverRepository, + keyPairRepository: keyPairRepository, + groupRepository: groupRepository, + aggregateRepository: aggregateRepository, + clock: clock, + idGen: idGen, } } func (s *Service) Reset() { s.serverRepository.Reset() + s.keyPairRepository.Reset() + s.groupRepository.Reset() + s.aggregateRepository.Reset() } diff --git a/internal/app/compute/types.go b/internal/app/compute/types.go index 19e4b97..959de99 100644 --- a/internal/app/compute/types.go +++ b/internal/app/compute/types.go @@ -34,3 +34,120 @@ type CreateServer struct { FlavorID string Metadata map[string]string } + +type KeyPair struct { + Name string + Fingerprint string + PublicKey string + PrivateKey string + UserID string + Type string +} + +type CreateKeyPair struct { + Name string + UserID string + Type string + PublicKey string +} + +type ServerAddress struct { + Version int + Address string +} + +type CreateServerGroup struct { + Name string + Policies []string + Policy string +} + +type ServerGroup struct { + ID string + Name string + Policies []string + Members []string + UserID string + ProjectID string + Metadata map[string]any + Policy string +} + +type ComputeService struct { + ID string + Binary string + DisabledReason string + ForcedDown bool + Host string + State string + Status string + UpdatedAt string + Zone string +} + +type Hypervisor struct { + ID string + CurrentWorkload int + Status string + State string + DiskAvailableLeast int + HostIP string + FreeDiskGB int + FreeRAMMB int + Hostname string + Type string + Version int + LocalGB int + LocalGBUsed int + MemoryMB int + MemoryMBUsed int + RunningVMs int + ServiceID string + VCPUs int + VCPUsUsed int +} + +type CreateAggregate struct { + Name string + AvailabilityZone string +} + +type Aggregate struct { + ID int + Name string + AvailabilityZone string + Hosts []string + Metadata map[string]string + CreatedAt string + UpdatedAt string + DeletedAt string + Deleted bool + UUID string +} + +type Limits struct { + MaxTotalCores int + MaxImageMeta int + MaxServerMeta int + MaxPersonality int + MaxPersonalitySize int + MaxTotalKeypairs int + MaxSecurityGroups int + MaxSecurityGroupRules int + MaxServerGroups int + MaxServerGroupMembers int + MaxTotalFloatingIps int + MaxTotalInstances int + MaxTotalRAMSize int + TotalCoresUsed int + TotalInstancesUsed int + TotalFloatingIpsUsed int + TotalRAMUsed int + TotalSecurityGroupsUsed int + TotalServerGroupsUsed int +} + +type AvailabilityZone struct { + Name string + Available bool +} diff --git a/internal/store/compute/memory_aggregate_repository.go b/internal/store/compute/memory_aggregate_repository.go new file mode 100644 index 0000000..e568609 --- /dev/null +++ b/internal/store/compute/memory_aggregate_repository.go @@ -0,0 +1,83 @@ +package compute + +import ( + "sync" + + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" +) + +type MemoryAggregateRepository struct { + mu sync.RWMutex + ids []int + aggregates map[int]appcompute.Aggregate +} + +func NewMemoryAggregateRepository() *MemoryAggregateRepository { + return &MemoryAggregateRepository{ + aggregates: map[int]appcompute.Aggregate{}, + } +} + +func (r *MemoryAggregateRepository) Create( + aggregate appcompute.Aggregate, +) appcompute.Aggregate { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, aggregate.ID) + r.aggregates[aggregate.ID] = aggregate + + return aggregate +} + +func (r *MemoryAggregateRepository) List() []appcompute.Aggregate { + r.mu.RLock() + defer r.mu.RUnlock() + + aggregates := make([]appcompute.Aggregate, 0, len(r.ids)) + for _, id := range r.ids { + aggregates = append(aggregates, r.aggregates[id]) + } + + return aggregates +} + +func (r *MemoryAggregateRepository) Get( + id int, +) (appcompute.Aggregate, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + aggregate, ok := r.aggregates[id] + if !ok { + return appcompute.Aggregate{}, appcompute.ErrAggregateNotFound + } + + return aggregate, nil +} + +func (r *MemoryAggregateRepository) Delete(id int) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.aggregates[id]; !ok { + return appcompute.ErrAggregateNotFound + } + delete(r.aggregates, id) + for index, existingID := range r.ids { + if existingID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryAggregateRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.aggregates = map[int]appcompute.Aggregate{} +} diff --git a/internal/store/compute/memory_keypair_repository.go b/internal/store/compute/memory_keypair_repository.go new file mode 100644 index 0000000..7b91942 --- /dev/null +++ b/internal/store/compute/memory_keypair_repository.go @@ -0,0 +1,85 @@ +package compute + +import ( + "sync" + + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" +) + +type MemoryKeyPairRepository struct { + mu sync.RWMutex + names []string + keyPairs map[string]appcompute.KeyPair +} + +func NewMemoryKeyPairRepository() *MemoryKeyPairRepository { + return &MemoryKeyPairRepository{ + names: []string{}, + keyPairs: map[string]appcompute.KeyPair{}, + } +} + +func (r *MemoryKeyPairRepository) Create( + keyPair appcompute.KeyPair, +) appcompute.KeyPair { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.keyPairs[keyPair.Name]; !ok { + r.names = append(r.names, keyPair.Name) + } + r.keyPairs[keyPair.Name] = keyPair + + return keyPair +} + +func (r *MemoryKeyPairRepository) List() []appcompute.KeyPair { + r.mu.RLock() + defer r.mu.RUnlock() + + keyPairs := make([]appcompute.KeyPair, 0, len(r.names)) + for _, name := range r.names { + keyPairs = append(keyPairs, r.keyPairs[name]) + } + + return keyPairs +} + +func (r *MemoryKeyPairRepository) Get(name string) (appcompute.KeyPair, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + keyPair, ok := r.keyPairs[name] + if !ok { + return appcompute.KeyPair{}, appcompute.ErrKeyPairNotFound + } + + return keyPair, nil +} + +func (r *MemoryKeyPairRepository) Delete(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.keyPairs[name]; !ok { + return appcompute.ErrKeyPairNotFound + } + + delete(r.keyPairs, name) + for index, currentName := range r.names { + if currentName == name { + r.names = append(r.names[:index], r.names[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryKeyPairRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.names = []string{} + r.keyPairs = map[string]appcompute.KeyPair{} +} diff --git a/internal/store/compute/memory_server_group_repository.go b/internal/store/compute/memory_server_group_repository.go new file mode 100644 index 0000000..efa856a --- /dev/null +++ b/internal/store/compute/memory_server_group_repository.go @@ -0,0 +1,83 @@ +package compute + +import ( + "sync" + + appcompute "github.com/JSYoo5B/SandStack/internal/app/compute" +) + +type MemoryServerGroupRepository struct { + mu sync.RWMutex + ids []string + groups map[string]appcompute.ServerGroup +} + +func NewMemoryServerGroupRepository() *MemoryServerGroupRepository { + return &MemoryServerGroupRepository{ + groups: map[string]appcompute.ServerGroup{}, + } +} + +func (r *MemoryServerGroupRepository) Create( + group appcompute.ServerGroup, +) appcompute.ServerGroup { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = append(r.ids, group.ID) + r.groups[group.ID] = group + + return group +} + +func (r *MemoryServerGroupRepository) List() []appcompute.ServerGroup { + r.mu.RLock() + defer r.mu.RUnlock() + + groups := make([]appcompute.ServerGroup, 0, len(r.ids)) + for _, id := range r.ids { + groups = append(groups, r.groups[id]) + } + + return groups +} + +func (r *MemoryServerGroupRepository) Get( + id string, +) (appcompute.ServerGroup, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + group, ok := r.groups[id] + if !ok { + return appcompute.ServerGroup{}, appcompute.ErrServerGroupNotFound + } + + return group, nil +} + +func (r *MemoryServerGroupRepository) Delete(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.groups[id]; !ok { + return appcompute.ErrServerGroupNotFound + } + delete(r.groups, id) + for index, existingID := range r.ids { + if existingID == id { + r.ids = append(r.ids[:index], r.ids[index+1:]...) + break + } + } + + return nil +} + +func (r *MemoryServerGroupRepository) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + + r.ids = nil + r.groups = map[string]appcompute.ServerGroup{} +}