diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go index 6ebe198..5bcbbed 100644 --- a/internal/domain/admin/handler_test.go +++ b/internal/domain/admin/handler_test.go @@ -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() @@ -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) diff --git a/internal/domain/admin/init.go b/internal/domain/admin/init.go index 35e5d50..eb2fb20 100644 --- a/internal/domain/admin/init.go +++ b/internal/domain/admin/init.go @@ -9,7 +9,8 @@ import ( type Option func(*options) type options struct { - health healthChecker + health healthChecker + statusSource instanceStatusSource } func WithHealthChecker(health healthChecker) Option { @@ -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) } diff --git a/internal/domain/admin/instance_status.go b/internal/domain/admin/instance_status.go new file mode 100644 index 0000000..aaf0708 --- /dev/null +++ b/internal/domain/admin/instance_status.go @@ -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 +} diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go index 87dbc2d..7aa8a81 100644 --- a/internal/domain/admin/service.go +++ b/internal/domain/admin/service.go @@ -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) { @@ -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) { @@ -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 { diff --git a/internal/server/app.go b/internal/server/app.go index c8b9793..f775478 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -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,