Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions internal/domain/admin/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ func (f fakeHealthChecker) CheckSSHGateway(context.Context) error { return f.ssh
func (f fakeHealthChecker) CheckNSProxy(context.Context) error { return f.nsErr }
func (f fakeHealthChecker) CheckHTTPProxy(context.Context) error { return f.httpErr }

type fakeInstanceStatusSource struct {
statuses map[string]string
err error
}

func (f fakeInstanceStatusSource) InstanceStatuses(context.Context) (map[string]string, error) {
return f.statuses, f.err
}

func (f fakeInstanceStatusSource) InstanceStatus(_ context.Context, id string) (string, error) {
if f.err != nil {
return "", f.err
}
return f.statuses[id], nil
}

func newAdminTestClient(t *testing.T, name string) *ent.Client {
t.Helper()

Expand Down Expand Up @@ -318,6 +334,101 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) {
})
}

func createTestUser(t *testing.T, client *ent.Client, email, name string) *ent.User {
t.Helper()
return client.User.Create().
SetEmail(email).
SetName(name).
SetGoogleID("google-" + name).
SetGoogleAccessToken("access").
SetGoogleRefreshToken("refresh").
SetGoogleTokenExpiry(time.Now()).
SaveX(context.Background())
}

func createTestInstance(t *testing.T, client *ent.Client, owner *ent.User, openstackID, status string) {
t.Helper()
client.Instance.Create().
SetOwner(owner).
SetOpenstackID(openstackID).
SetName(openstackID).
SetStatus(status).
SetImageID("ubuntu").
SetFlavorID("m1.small").
SetProviderCreatedAt(time.Now()).
SaveX(context.Background())
}

func TestServiceOverlaysLiveInstanceStatus(t *testing.T) {
ctx := context.Background()
client := newAdminTestClient(t, "admin-live-status")
student := createTestUser(t, client, "student@return.dev", "student")

// DB status is stale ("BUILD"); vm-gone is absent from live data.
createTestInstance(t, client, student, "vm-live", "BUILD")
createTestInstance(t, client, student, "vm-gone", "BUILD")

svc := NewService(NewRepository(client), nil, fakeInstanceStatusSource{
statuses: map[string]string{"vm-live": "ACTIVE"},
})

statusOf := func(items []InstanceResponse, id string) string {
for _, item := range items {
if item.ID == id {
return item.Status
}
}
t.Fatalf("instance %s missing from %+v", id, items)
return ""
}

list, err := svc.Instances(ctx, "1", "10")
if err != nil {
t.Fatalf("Instances: %v", err)
}
if got := statusOf(list.Items, "vm-live"); got != "ACTIVE" {
t.Fatalf("list: expected ACTIVE, got %q", got)
}
if got := statusOf(list.Items, "vm-gone"); got != "BUILD" {
t.Fatalf("list: expected fallback BUILD, got %q", got)
}

detail, err := svc.Instance(ctx, "vm-live")
if err != nil {
t.Fatalf("Instance: %v", err)
}
if detail.Status != "ACTIVE" {
t.Fatalf("detail: expected ACTIVE, got %q", detail.Status)
}

res, err := svc.UserResources(ctx, student.ID.String())
if err != nil {
t.Fatalf("UserResources: %v", err)
}
if got := statusOf(res.Instances, "vm-live"); got != "ACTIVE" {
t.Fatalf("user resources: expected ACTIVE, got %q", got)
}
}

func TestServiceFallsBackToDBStatusWhenLiveFetchFails(t *testing.T) {
ctx := context.Background()
client := newAdminTestClient(t, "admin-live-status-error")
student := createTestUser(t, client, "student@return.dev", "student")
createTestInstance(t, client, student, "vm-live", "ACTIVE")

svc := NewService(NewRepository(client), nil, fakeInstanceStatusSource{
err: errors.New("openstack unreachable"),
})

list, err := svc.Instances(ctx, "1", "10")
if err != nil {
t.Fatalf("Instances: %v", err)
}
if len(list.Items) != 1 || list.Items[0].Status != "ACTIVE" {
t.Fatalf("expected DB status fallback, got %+v", list.Items)
}
}

func TestAdminSystemReturnsRealHealthStatuses(t *testing.T) {
gin.SetMode(gin.TestMode)

Expand Down
15 changes: 13 additions & 2 deletions internal/domain/admin/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import (
type Option func(*options)

type options struct {
health healthChecker
health healthChecker
statusSource instanceStatusSource
}

func WithHealthChecker(health healthChecker) Option {
Expand All @@ -22,12 +23,22 @@ func WithLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock,
return WithHealthChecker(NewLiveHealthChecker(provider, sshGatewaySock, nsProxySock, httpProxyAddress))
}

func WithInstanceStatusSource(source instanceStatusSource) Option {
return func(opts *options) {
opts.statusSource = source
}
}

func WithLiveInstanceStatusSource(provider *gophercloud.ProviderClient) Option {
return WithInstanceStatusSource(NewLiveInstanceStatusSource(provider))
}

func Init(entClient *ent.Client, opts ...Option) *Handler {
cfg := options{}
for _, opt := range opts {
opt(&cfg)
}
repo := NewRepository(entClient)
svc := NewService(repo, cfg.health)
svc := NewService(repo, cfg.health, cfg.statusSource)
return NewHandler(svc)
}
44 changes: 44 additions & 0 deletions internal/domain/admin/instance_status.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package admin

import (
"context"

"github.com/gophercloud/gophercloud"

"github.com/KHU-RETURN/rcp-server/internal/domain/compute"
)

type instanceStatusSource interface {
InstanceStatuses(ctx context.Context) (map[string]string, error)
InstanceStatus(ctx context.Context, id string) (string, error)
}

// liveInstanceStatusSource reuses the compute client so the admin views read the
// same live OpenStack status as the user-facing compute views.
type liveInstanceStatusSource struct {
client *compute.Client
}

func NewLiveInstanceStatusSource(provider *gophercloud.ProviderClient) instanceStatusSource {
return &liveInstanceStatusSource{client: compute.NewClient(provider)}
}

func (s *liveInstanceStatusSource) InstanceStatuses(context.Context) (map[string]string, error) {
servers, err := s.client.FetchInstances()
if err != nil {
return nil, err
}
statuses := make(map[string]string, len(servers))
for _, srv := range servers {
statuses[srv.ID] = srv.Status
}
return statuses, nil
}

func (s *liveInstanceStatusSource) InstanceStatus(_ context.Context, id string) (string, error) {
srv, err := s.client.FetchInstance(id)
if err != nil {
return "", err
}
return srv.Status, nil
}
50 changes: 43 additions & 7 deletions internal/domain/admin/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ const (
)

type Service struct {
repo *Repository
health healthChecker
repo *Repository
health healthChecker
statusSource instanceStatusSource
}

func NewService(repo *Repository, health healthChecker) *Service {
return &Service{repo: repo, health: health}
func NewService(repo *Repository, health healthChecker, statusSource instanceStatusSource) *Service {
return &Service{repo: repo, health: health, statusSource: statusSource}
}

func (s *Service) Summary(ctx context.Context) (SummaryResponse, error) {
Expand All @@ -30,11 +31,25 @@ func (s *Service) Users(ctx context.Context, rawPage, rawLimit string) (Paginate
}

func (s *Service) Instances(ctx context.Context, rawPage, rawLimit string) (PaginatedInstancesResponse, error) {
return s.repo.Instances(ctx, parsePageParams(rawPage, rawLimit))
res, err := s.repo.Instances(ctx, parsePageParams(rawPage, rawLimit))
if err != nil {
return PaginatedInstancesResponse{}, err
}
s.overlayLiveStatuses(ctx, res.Items)
return res, nil
}

func (s *Service) Instance(ctx context.Context, id string) (InstanceResponse, error) {
return s.repo.Instance(ctx, id)
res, err := s.repo.Instance(ctx, id)
if err != nil {
return InstanceResponse{}, err
}
if s.statusSource != nil {
if live, statusErr := s.statusSource.InstanceStatus(ctx, res.ID); statusErr == nil && live != "" {
res.Status = live
}
}
return res, nil
}

func (s *Service) Containers(ctx context.Context, rawPage, rawLimit string) (PaginatedContainersResponse, error) {
Expand All @@ -46,7 +61,28 @@ func (s *Service) Container(ctx context.Context, id string) (ContainerResponse,
}

func (s *Service) UserResources(ctx context.Context, id string) (UserResourcesResponse, error) {
return s.repo.UserResources(ctx, id)
res, err := s.repo.UserResources(ctx, id)
if err != nil {
return UserResourcesResponse{}, err
}
s.overlayLiveStatuses(ctx, res.Instances)
return res, nil
}

// overlayLiveStatuses replaces stale DB statuses with live OpenStack statuses, best-effort.
func (s *Service) overlayLiveStatuses(ctx context.Context, items []InstanceResponse) {
if s.statusSource == nil || len(items) == 0 {
return
}
statuses, err := s.statusSource.InstanceStatuses(ctx)
if err != nil {
return
}
for i := range items {
if live, ok := statuses[items[i].ID]; ok && live != "" {
items[i].Status = live
}
}
}

func (s *Service) System(ctx context.Context) SystemResponse {
Expand Down
1 change: 1 addition & 0 deletions internal/server/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ func NewApp(deps AppDeps) (*App, error) {
Admin: admin.Init(
deps.EntClient,
admin.WithLiveHealthChecker(deps.Provider, deps.SSHGatewaySock, deps.NSProxySock, deps.HTTPProxyAddress),
admin.WithLiveInstanceStatusSource(deps.Provider),
),
Apps: apps.Init(deps.EntClient),
Auth: authHandler,
Expand Down