From 7319f7198590209013975fd6433a707e306e7926 Mon Sep 17 00:00:00 2001 From: Chae-JS Date: Sat, 6 Jun 2026 15:37:46 +0900 Subject: [PATCH 1/5] feat: add admin dashboard API --- .github/workflows/deploy.yml | 3 +- internal/domain/admin/handler.go | 58 ++++++++ internal/domain/admin/handler_test.go | 203 ++++++++++++++++++++++++++ internal/domain/admin/init.go | 9 ++ internal/domain/admin/repository.go | 140 ++++++++++++++++++ internal/domain/admin/service.go | 54 +++++++ internal/domain/admin/types.go | 50 +++++++ internal/domain/auth/filter.go | 41 ++++++ internal/domain/auth/filter_test.go | 43 ++++++ internal/domain/auth/handler.go | 1 + internal/domain/auth/handler_test.go | 32 ++++ internal/domain/auth/types.go | 2 + internal/server/app.go | 3 + internal/server/router.go | 7 + internal/server/router_test.go | 26 ++++ 15 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 internal/domain/admin/handler.go create mode 100644 internal/domain/admin/handler_test.go create mode 100644 internal/domain/admin/init.go create mode 100644 internal/domain/admin/repository.go create mode 100644 internal/domain/admin/service.go create mode 100644 internal/domain/admin/types.go diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3c7fe4a..1999466 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -57,10 +57,11 @@ jobs: - name: Write .env file uses: appleboy/ssh-action@v0.1.10 env: - ENV_LIST: RCP_JWT_SECRET,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET + ENV_LIST: RCP_JWT_SECRET,RCP_ADMIN_EMAILS,OS_AUTH_URL,OS_USERNAME,OS_PASSWORD,OS_PROJECT_NAME,OS_USER_DOMAIN_NAME,OS_PROJECT_ID,CF_ACCESS_CLIENT_ID,CF_ACCESS_CLIENT_SECRET,GG_OAUTH_CLIENT,GG_OAUTH_SECRET,GG_REDIRECT_URL,DB_DRIVER,DB_DSN,PORT,RCP_NS_PROXY_SOCK,RCP_WEB_CONSOLE_BASE_URL,RCP_WEB_CONSOLE_ALLOWED_ORIGINS,RCP_VM_KNOWN_HOSTS_PATH,RCP_SSH_GW_KNOWN_HOSTS_PATH,FRONTEND_URL,RCP_FRONTEND_BASE_URL,RCP_ALLOWED_FRONTEND_ORIGIN_PATTERN,RCP_AUTH_COOKIE_SAMESITE,RCP_AUTH_COOKIE_SECURE,RCP_ALLOWED_ORIGINS,RCP_ALLOWED_ORIGIN_PATTERN,RCP_DEFAULT_NETWORK_ID,RCP_SSH_GW_NOTIFY_SOCK,RCP_SSH_GW_NOTIFY_SECRET FRONTEND_URL: ${{ secrets.FRONTEND_URL }} RCP_FRONTEND_BASE_URL: ${{ secrets.RCP_FRONTEND_BASE_URL }} RCP_JWT_SECRET: ${{ secrets.RCP_JWT_SECRET }} + RCP_ADMIN_EMAILS: ${{ secrets.RCP_ADMIN_EMAILS }} OS_AUTH_URL: ${{ secrets.OS_AUTH_URL }} OS_USERNAME: ${{ secrets.OS_USERNAME }} OS_PASSWORD: ${{ secrets.OS_PASSWORD }} diff --git a/internal/domain/admin/handler.go b/internal/domain/admin/handler.go new file mode 100644 index 0000000..7901429 --- /dev/null +++ b/internal/domain/admin/handler.go @@ -0,0 +1,58 @@ +package admin + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/KHU-RETURN/rcp-server/internal/api" +) + +type Handler struct { + Svc *Service +} + +func NewHandler(svc *Service) *Handler { + return &Handler{Svc: svc} +} + +func (h *Handler) InitRoutes(rg *gin.RouterGroup) { + adminGroup := rg.Group("/admin") + { + adminGroup.GET("/summary", h.Summary) + adminGroup.GET("/users", h.Users) + adminGroup.GET("/instances", h.Instances) + adminGroup.GET("/system", h.System) + } +} + +func (h *Handler) Summary(c *gin.Context) { + res, err := h.Svc.Summary(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + +func (h *Handler) Users(c *gin.Context) { + res, err := h.Svc.Users(c.Request.Context(), c.Query("limit")) + if err != nil { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + +func (h *Handler) Instances(c *gin.Context) { + res, err := h.Svc.Instances(c.Request.Context(), c.Query("limit")) + if err != nil { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + +func (h *Handler) System(c *gin.Context) { + c.JSON(http.StatusOK, h.Svc.System()) +} diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go new file mode 100644 index 0000000..f54b1e8 --- /dev/null +++ b/internal/domain/admin/handler_test.go @@ -0,0 +1,203 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + _ "modernc.org/sqlite" + + "github.com/KHU-RETURN/rcp-server/ent" + "github.com/KHU-RETURN/rcp-server/ent/migrate" + "github.com/KHU-RETURN/rcp-server/internal/domain/auth" +) + +func newAdminTestClient(t *testing.T, name string) *ent.Client { + t.Helper() + + db, err := sql.Open("sqlite", "file:"+name+"?mode=memory&cache=shared&_pragma=foreign_keys(1)") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + drv := entsql.OpenDB(dialect.SQLite, db) + client := ent.NewClient(ent.Driver(drv)) + if err := client.Schema.Create(context.Background(), migrate.WithDropIndex(true)); err != nil { + t.Fatalf("create schema: %v", err) + } + t.Cleanup(func() { + _ = client.Close() + _ = db.Close() + }) + return client +} + +func TestAdminDashboardEndpointsRequireAdminRole(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("RCP_ADMIN_EMAILS", "admin@return.dev") + + client := newAdminTestClient(t, "admin-authz") + + handler := Init(client) + router := gin.New() + group := router.Group("/api/v1") + group.Use(func(c *gin.Context) { + c.Set(auth.ContextKeyUser, &auth.User{ + ID: uuid.New(), + Email: "student@return.dev", + Name: "Student", + }) + }) + group.Use(auth.AdminRequired()) + handler.InitRoutes(group) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/summary", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } +} + +func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("RCP_ADMIN_EMAILS", "admin@return.dev") + + ctx := context.Background() + client := newAdminTestClient(t, "admin-dashboard") + + adminUser := client.User.Create(). + SetEmail("admin@return.dev"). + SetName("Admin"). + SetGoogleID("google-admin"). + SetGoogleAccessToken("access"). + SetGoogleRefreshToken("refresh"). + SetGoogleTokenExpiry(time.Now()). + SaveX(ctx) + student := client.User.Create(). + SetEmail("student@return.dev"). + SetName("Student"). + SetGoogleID("google-student"). + SetGoogleAccessToken("access"). + SetGoogleRefreshToken("refresh"). + SetGoogleTokenExpiry(time.Now()). + SaveX(ctx) + + instance := client.Instance.Create(). + SetOwner(student). + SetOpenstackID("vm-001"). + SetName("student-web"). + SetStatus("ACTIVE"). + SetImageID("ubuntu"). + SetFlavorID("m1.small"). + SetProviderCreatedAt(time.Now()). + SaveX(ctx) + client.Container.Create(). + SetOwner(student). + SetOpenstackName(uuid.New()). + SetName("student-bucket"). + SaveX(ctx) + client.KeyPair.Create(). + SetOwner(student). + SetOpenstackName("student-key"). + SetFingerprint("fp"). + SetPublicKey("ssh-ed25519 AAAA"). + SetSourceType("user_uploaded"). + SaveX(ctx) + client.App.Create(). + SetInstance(instance). + SetHost("student.rcp.dev"). + SaveX(ctx) + + router := gin.New() + group := router.Group("/api/v1") + group.Use(func(c *gin.Context) { + c.Set(auth.ContextKeyUser, &auth.User{ + ID: adminUser.ID, + Email: adminUser.Email, + Name: adminUser.Name, + }) + }) + group.Use(auth.AdminRequired()) + Init(client).InitRoutes(group) + + t.Run("summary counts users and resources", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/summary", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body SummaryResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode summary: %v", err) + } + if body.Users != 2 || body.Instances != 1 || body.Containers != 1 || body.Apps != 1 || body.Keypairs != 1 { + t.Fatalf("unexpected summary: %+v", body) + } + if body.StatusCounts["ACTIVE"] != 1 { + t.Fatalf("expected ACTIVE count 1, got %+v", body.StatusCounts) + } + }) + + t.Run("users include resource counts", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []UserResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode users: %v", err) + } + if len(body) != 2 { + t.Fatalf("expected 2 users, got %d", len(body)) + } + var foundStudent *UserResponse + for i := range body { + if body[i].Email == "student@return.dev" { + foundStudent = &body[i] + } + } + if foundStudent == nil { + t.Fatal("student user missing") + } + if foundStudent.InstanceCount != 1 || foundStudent.ContainerCount != 1 || foundStudent.AppCount != 1 || foundStudent.KeypairCount != 1 { + t.Fatalf("unexpected resource counts: %+v", *foundStudent) + } + }) + + t.Run("instances include owner and app details", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/instances", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body []InstanceResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode instances: %v", err) + } + if len(body) != 1 { + t.Fatalf("expected 1 instance, got %d", len(body)) + } + if body[0].OwnerEmail != "student@return.dev" || body[0].AppHost != "student.rcp.dev" { + t.Fatalf("unexpected instance response: %+v", body[0]) + } + }) +} diff --git a/internal/domain/admin/init.go b/internal/domain/admin/init.go new file mode 100644 index 0000000..04b04a7 --- /dev/null +++ b/internal/domain/admin/init.go @@ -0,0 +1,9 @@ +package admin + +import "github.com/KHU-RETURN/rcp-server/ent" + +func Init(entClient *ent.Client) *Handler { + repo := NewRepository(entClient) + svc := NewService(repo) + return NewHandler(svc) +} diff --git a/internal/domain/admin/repository.go b/internal/domain/admin/repository.go new file mode 100644 index 0000000..58ba5d8 --- /dev/null +++ b/internal/domain/admin/repository.go @@ -0,0 +1,140 @@ +package admin + +import ( + "context" + "strings" + + "entgo.io/ent/dialect/sql" + "github.com/KHU-RETURN/rcp-server/ent" + "github.com/KHU-RETURN/rcp-server/ent/instance" + "github.com/KHU-RETURN/rcp-server/ent/user" + "github.com/KHU-RETURN/rcp-server/internal/domain/auth" +) + +type Repository struct { + client *ent.Client +} + +func NewRepository(client *ent.Client) *Repository { + return &Repository{client: client} +} + +func (r *Repository) Summary(ctx context.Context) (SummaryResponse, error) { + users, err := r.client.User.Query().Count(ctx) + if err != nil { + return SummaryResponse{}, err + } + instances, err := r.client.Instance.Query().Count(ctx) + if err != nil { + return SummaryResponse{}, err + } + containers, err := r.client.Container.Query().Count(ctx) + if err != nil { + return SummaryResponse{}, err + } + apps, err := r.client.App.Query().Count(ctx) + if err != nil { + return SummaryResponse{}, err + } + keypairs, err := r.client.KeyPair.Query().Count(ctx) + if err != nil { + return SummaryResponse{}, err + } + rows, err := r.client.Instance.Query().All(ctx) + if err != nil { + return SummaryResponse{}, err + } + + statusCounts := make(map[string]int) + for _, row := range rows { + status := strings.ToUpper(strings.TrimSpace(row.Status)) + if status == "" { + status = "UNKNOWN" + } + statusCounts[status]++ + } + + return SummaryResponse{ + Users: users, + Instances: instances, + Containers: containers, + Apps: apps, + Keypairs: keypairs, + StatusCounts: statusCounts, + }, nil +} + +func (r *Repository) Users(ctx context.Context, limit int) ([]UserResponse, error) { + rows, err := r.client.User.Query(). + WithInstances(func(q *ent.InstanceQuery) { + q.WithApp() + }). + WithContainers(). + WithKeypairs(). + Order(user.ByCreatedAt(sql.OrderDesc()), user.ByEmail()). + Limit(limit). + All(ctx) + if err != nil { + return nil, err + } + + result := make([]UserResponse, 0, len(rows)) + for _, row := range rows { + appCount := 0 + for _, inst := range row.Edges.Instances { + if inst.Edges.App != nil { + appCount++ + } + } + result = append(result, UserResponse{ + ID: row.ID.String(), + Email: row.Email, + Name: row.Name, + Role: auth.RoleForEmail(row.Email), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + InstanceCount: len(row.Edges.Instances), + ContainerCount: len(row.Edges.Containers), + AppCount: appCount, + KeypairCount: len(row.Edges.Keypairs), + }) + } + return result, nil +} + +func (r *Repository) Instances(ctx context.Context, limit int) ([]InstanceResponse, error) { + rows, err := r.client.Instance.Query(). + WithOwner(). + WithApp(). + Order(instance.ByCreatedAt(sql.OrderDesc()), instance.ByOpenstackID()). + Limit(limit). + All(ctx) + if err != nil { + return nil, err + } + + result := make([]InstanceResponse, 0, len(rows)) + for _, row := range rows { + owner := row.Edges.Owner + app := row.Edges.App + item := InstanceResponse{ + ID: row.OpenstackID, + Name: row.Name, + Status: row.Status, + FlavorID: row.FlavorID, + ImageID: row.ImageID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if owner != nil { + item.OwnerID = owner.ID.String() + item.OwnerEmail = owner.Email + item.OwnerName = owner.Name + } + if app != nil { + item.AppHost = app.Host + } + result = append(result, item) + } + return result, nil +} diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go new file mode 100644 index 0000000..3ae2cb6 --- /dev/null +++ b/internal/domain/admin/service.go @@ -0,0 +1,54 @@ +package admin + +import ( + "context" + "strconv" + "time" +) + +const ( + defaultLimit = 100 + maxLimit = 500 +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *Service { + return &Service{repo: repo} +} + +func (s *Service) Summary(ctx context.Context) (SummaryResponse, error) { + return s.repo.Summary(ctx) +} + +func (s *Service) Users(ctx context.Context, rawLimit string) ([]UserResponse, error) { + return s.repo.Users(ctx, parseLimit(rawLimit)) +} + +func (s *Service) Instances(ctx context.Context, rawLimit string) ([]InstanceResponse, error) { + return s.repo.Instances(ctx, parseLimit(rawLimit)) +} + +func (s *Service) System() SystemResponse { + return SystemResponse{ + APIStatus: "healthy", + OpenStackStatus: "unknown", + SSHGatewayStatus: "unknown", + StorageStatus: "unknown", + LastUpdatedAt: time.Now().UTC(), + Message: "System status is limited to API health until provider health checks are wired.", + } +} + +func parseLimit(raw string) int { + limit, err := strconv.Atoi(raw) + if err != nil || limit <= 0 { + return defaultLimit + } + if limit > maxLimit { + return maxLimit + } + return limit +} diff --git a/internal/domain/admin/types.go b/internal/domain/admin/types.go new file mode 100644 index 0000000..3ea97ff --- /dev/null +++ b/internal/domain/admin/types.go @@ -0,0 +1,50 @@ +package admin + +import "time" + +type SummaryResponse struct { + Users int `json:"users"` + Instances int `json:"instances"` + Containers int `json:"containers"` + Apps int `json:"apps"` + Keypairs int `json:"keypairs"` + StatusCounts map[string]int `json:"status_counts"` +} + +type UserResponse struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + InstanceCount int `json:"instance_count"` + ContainerCount int `json:"container_count"` + AppCount int `json:"app_count"` + KeypairCount int `json:"keypair_count"` +} + +type InstanceResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + OwnerID string `json:"owner_id"` + OwnerEmail string `json:"owner_email"` + OwnerName string `json:"owner_name"` + FlavorID string `json:"flavor_id"` + FlavorName string `json:"flavor_name"` + ImageID string `json:"image_id"` + FixedIP string `json:"fixed_ip"` + AppHost string `json:"app_host"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SystemResponse struct { + APIStatus string `json:"api_status"` + OpenStackStatus string `json:"openstack_status"` + SSHGatewayStatus string `json:"ssh_gateway_status"` + StorageStatus string `json:"storage_status"` + LastUpdatedAt time.Time `json:"last_updated_at"` + Message string `json:"message"` +} diff --git a/internal/domain/auth/filter.go b/internal/domain/auth/filter.go index a3455c5..24e6c4d 100644 --- a/internal/domain/auth/filter.go +++ b/internal/domain/auth/filter.go @@ -3,6 +3,7 @@ package auth import ( "errors" "net/http" + "os" "strings" "github.com/gin-gonic/gin" @@ -15,6 +16,7 @@ const ( headerAuthorization = "Authorization" schemeBearer = "Bearer" + envAdminEmails = "RCP_ADMIN_EMAILS" ) var errInvalidAuthorizationHeader = errors.New("invalid authorization header") @@ -50,6 +52,45 @@ func (h *Handler) AuthRequired() gin.HandlerFunc { } } +func AdminRequired() gin.HandlerFunc { + return func(c *gin.Context) { + val, exists := c.Get(ContextKeyUser) + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: ErrUserNotFound.Error()}) + return + } + + user, ok := val.(*User) + if !ok || !IsAdminEmail(user.Email) { + c.AbortWithStatusJSON(http.StatusForbidden, ErrorResponse{Error: "admin access required"}) + return + } + + c.Next() + } +} + +func IsAdminEmail(email string) bool { + target := strings.ToLower(strings.TrimSpace(email)) + if target == "" { + return false + } + + for _, item := range strings.Split(os.Getenv(envAdminEmails), ",") { + if strings.ToLower(strings.TrimSpace(item)) == target { + return true + } + } + return false +} + +func RoleForEmail(email string) string { + if IsAdminEmail(email) { + return "admin" + } + return "user" +} + func accessTokenFromRequest(c *gin.Context) (string, error) { header := c.GetHeader(headerAuthorization) if strings.TrimSpace(header) != "" { diff --git a/internal/domain/auth/filter_test.go b/internal/domain/auth/filter_test.go index 0df374d..fedd466 100644 --- a/internal/domain/auth/filter_test.go +++ b/internal/domain/auth/filter_test.go @@ -182,3 +182,46 @@ func TestAuthRequired(t *testing.T) { } }) } + +func TestAdminRequiredUsesConfiguredAdminEmails(t *testing.T) { + gin.SetMode(gin.TestMode) + + newAdminRouter := func(user *User) *gin.Engine { + r := gin.New() + protected := r.Group("/protected") + protected.Use(func(c *gin.Context) { + c.Set(ContextKeyUser, user) + }) + protected.Use(AdminRequired()) + protected.GET("", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + return r + } + + t.Run("allows configured admin email regardless of stored role", func(t *testing.T) { + t.Setenv("RCP_ADMIN_EMAILS", "admin@return.dev, operator@return.dev") + router := newAdminRouter(&User{Email: "operator@return.dev", Role: "user"}) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + }) + + t.Run("rejects unconfigured email even when stored role is admin", func(t *testing.T) { + t.Setenv("RCP_ADMIN_EMAILS", "admin@return.dev") + router := newAdminRouter(&User{Email: "student@return.dev", Role: "admin"}) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", w.Code) + } + }) +} diff --git a/internal/domain/auth/handler.go b/internal/domain/auth/handler.go index 673b93f..7d294c9 100644 --- a/internal/domain/auth/handler.go +++ b/internal/domain/auth/handler.go @@ -206,6 +206,7 @@ func (h *Handler) Me(c *gin.Context) { ID: user.ID, Name: user.Name, Email: user.Email, + Role: RoleForEmail(user.Email), AccessToken: token, }) } diff --git a/internal/domain/auth/handler_test.go b/internal/domain/auth/handler_test.go index 36c35cd..dc55701 100644 --- a/internal/domain/auth/handler_test.go +++ b/internal/domain/auth/handler_test.go @@ -174,6 +174,38 @@ func TestHandlerRefresh(t *testing.T) { }) } +func TestHandlerMeReturnsAdminRoleForConfiguredEmail(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("RCP_ADMIN_EMAILS", "admin@return.dev") + + email := "admin@return.dev" + repo := &fakeRepo{users: map[string]*User{ + email: {Email: email, Name: "Admin", Role: "user"}, + }} + router, tokenSvc := newAuthRouter(repo) + pair, err := tokenSvc.GenerateAuthTokens(email) + if err != nil { + t.Fatalf("GenerateAuthTokens: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+pair.AccessToken) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body MeResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Role != "admin" { + t.Fatalf("expected role admin, got %q", body.Role) + } +} + func TestHandlerLogout(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/internal/domain/auth/types.go b/internal/domain/auth/types.go index eadd34f..521dbbe 100644 --- a/internal/domain/auth/types.go +++ b/internal/domain/auth/types.go @@ -11,6 +11,7 @@ type User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` Name string `json:"name"` + Role string `json:"role"` GoogleID string `json:"-"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -26,6 +27,7 @@ type MeResponse struct { ID uuid.UUID `json:"id"` Email string `json:"email"` Name string `json:"name"` + Role string `json:"role"` AccessToken string `json:"access_token"` } diff --git a/internal/server/app.go b/internal/server/app.go index d6013a7..3125eee 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -8,6 +8,7 @@ import ( "github.com/KHU-RETURN/rcp-server/ent" "github.com/KHU-RETURN/rcp-server/internal/domain/access" + "github.com/KHU-RETURN/rcp-server/internal/domain/admin" "github.com/KHU-RETURN/rcp-server/internal/domain/apps" "github.com/KHU-RETURN/rcp-server/internal/domain/auth" "github.com/KHU-RETURN/rcp-server/internal/domain/compute" @@ -17,6 +18,7 @@ import ( type App struct { Compute *compute.Handler Access *access.Handler + Admin *admin.Handler Apps *apps.Handler Auth *auth.Handler Storage *storage.Handler @@ -47,6 +49,7 @@ func NewApp(deps AppDeps) (*App, error) { return &App{ Compute: compute.Init(deps.Provider, deps.EntClient, deps.OpenStackProject, deps.DefaultNetworkID), Access: access.Init(deps.Provider, deps.EntClient, deps.SSHGatewaySecret), + Admin: admin.Init(deps.EntClient), Apps: apps.Init(deps.EntClient), Auth: authHandler, Storage: storage.Init(deps.Provider, deps.EntClient), diff --git a/internal/server/router.go b/internal/server/router.go index 9d6c4cf..16320b8 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/KHU-RETURN/rcp-server/internal/api" + "github.com/KHU-RETURN/rcp-server/internal/domain/auth" ) func NewRouter(app *App) *gin.Engine { @@ -30,6 +31,12 @@ func NewRouter(app *App) *gin.Engine { app.Apps.InitRoutes(protected) app.Compute.InitRoutes(protected) app.Storage.InitRoutes(protected) + + if app.Auth != nil && app.Admin != nil { + adminGroup := v1.Group("/") + adminGroup.Use(app.Auth.AuthRequired(), auth.AdminRequired()) + app.Admin.InitRoutes(adminGroup) + } } return r diff --git a/internal/server/router_test.go b/internal/server/router_test.go index 1ceef1d..6327ae8 100644 --- a/internal/server/router_test.go +++ b/internal/server/router_test.go @@ -9,6 +9,7 @@ import ( "github.com/KHU-RETURN/rcp-server/internal/api" "github.com/KHU-RETURN/rcp-server/internal/domain/access" + "github.com/KHU-RETURN/rcp-server/internal/domain/admin" "github.com/KHU-RETURN/rcp-server/internal/domain/auth" "github.com/KHU-RETURN/rcp-server/internal/domain/compute" "github.com/KHU-RETURN/rcp-server/internal/domain/storage" @@ -19,6 +20,7 @@ func TestNewRouterRegistersComputeRoutes(t *testing.T) { router := NewRouter(&App{ Access: &access.Handler{}, + Admin: &admin.Handler{}, Auth: &auth.Handler{}, Compute: &compute.Handler{}, Storage: &storage.Handler{}, @@ -34,6 +36,7 @@ func TestNewRouterRegistersComputeRoutes(t *testing.T) { var foundStorageContainers bool var foundStorageUploadObject bool var foundStorageArchiveObjects bool + var foundAdminSummary bool for _, route := range routes { if route.Method == http.MethodGet && route.Path == api.BasePath+"/auth/me" { @@ -63,6 +66,9 @@ func TestNewRouterRegistersComputeRoutes(t *testing.T) { if route.Method == http.MethodGet && route.Path == api.BasePath+"/storage/containers/:name/archive" { foundStorageArchiveObjects = true } + if route.Method == http.MethodGet && route.Path == api.BasePath+"/admin/summary" { + foundAdminSummary = true + } } if !foundFlavors { @@ -92,6 +98,26 @@ func TestNewRouterRegistersComputeRoutes(t *testing.T) { if !foundStorageArchiveObjects { t.Fatalf("%s %s/storage/containers/:name/archive route was not registered", http.MethodGet, api.BasePath) } + if !foundAdminSummary { + t.Fatalf("%s %s/admin/summary route was not registered", http.MethodGet, api.BasePath) + } +} + +func TestNewRouterDoesNotRegisterAdminRoutesWithoutAuth(t *testing.T) { + setGinMode(t, gin.TestMode) + + router := NewRouter(&App{ + Access: &access.Handler{}, + Admin: &admin.Handler{}, + Compute: &compute.Handler{}, + Storage: &storage.Handler{}, + }) + + for _, route := range router.Routes() { + if route.Path == api.BasePath+"/admin/summary" { + t.Fatal("admin route must not be registered without auth middleware") + } + } } func TestRouterCORSAllowsFrontendOrigin(t *testing.T) { From 3b7d66b10cbb01833f98bbba80e7909a8bc71c54 Mon Sep 17 00:00:00 2001 From: Chae-JS Date: Sat, 6 Jun 2026 15:50:05 +0900 Subject: [PATCH 2/5] feat: paginate admin resources --- internal/domain/admin/handler.go | 36 +++- internal/domain/admin/handler_test.go | 80 ++++++-- internal/domain/admin/repository.go | 283 +++++++++++++++++++++----- internal/domain/admin/service.go | 39 ++-- internal/domain/admin/types.go | 68 +++++++ 5 files changed, 433 insertions(+), 73 deletions(-) diff --git a/internal/domain/admin/handler.go b/internal/domain/admin/handler.go index 7901429..8037d66 100644 --- a/internal/domain/admin/handler.go +++ b/internal/domain/admin/handler.go @@ -3,7 +3,9 @@ package admin import ( "net/http" + "github.com/KHU-RETURN/rcp-server/ent" "github.com/gin-gonic/gin" + "github.com/google/uuid" "github.com/KHU-RETURN/rcp-server/internal/api" ) @@ -21,7 +23,9 @@ func (h *Handler) InitRoutes(rg *gin.RouterGroup) { { adminGroup.GET("/summary", h.Summary) adminGroup.GET("/users", h.Users) + adminGroup.GET("/users/:id/resources", h.UserResources) adminGroup.GET("/instances", h.Instances) + adminGroup.GET("/containers", h.Containers) adminGroup.GET("/system", h.System) } } @@ -36,7 +40,7 @@ func (h *Handler) Summary(c *gin.Context) { } func (h *Handler) Users(c *gin.Context) { - res, err := h.Svc.Users(c.Request.Context(), c.Query("limit")) + res, err := h.Svc.Users(c.Request.Context(), c.Query("page"), c.Query("limit")) if err != nil { c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) return @@ -45,7 +49,7 @@ func (h *Handler) Users(c *gin.Context) { } func (h *Handler) Instances(c *gin.Context) { - res, err := h.Svc.Instances(c.Request.Context(), c.Query("limit")) + res, err := h.Svc.Instances(c.Request.Context(), c.Query("page"), c.Query("limit")) if err != nil { c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) return @@ -53,6 +57,34 @@ func (h *Handler) Instances(c *gin.Context) { c.JSON(http.StatusOK, res) } +func (h *Handler) Containers(c *gin.Context) { + res, err := h.Svc.Containers(c.Request.Context(), c.Query("page"), c.Query("limit")) + if err != nil { + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + +func (h *Handler) UserResources(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Error: "invalid user id"}) + return + } + + res, err := h.Svc.UserResources(c.Request.Context(), id) + if err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, api.ErrorResponse{Error: "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + func (h *Handler) System(c *gin.Context) { c.JSON(http.StatusOK, h.Svc.System()) } diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go index f54b1e8..b9a8616 100644 --- a/internal/domain/admin/handler_test.go +++ b/internal/domain/admin/handler_test.go @@ -151,7 +151,7 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { }) t.Run("users include resource counts", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users?page=1&limit=10", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -159,17 +159,20 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - var body []UserResponse + var body PaginatedUsersResponse if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode users: %v", err) } - if len(body) != 2 { - t.Fatalf("expected 2 users, got %d", len(body)) + if body.Pagination.Page != 1 || body.Pagination.PerPage != 10 || body.Pagination.Total != 2 { + t.Fatalf("unexpected pagination: %+v", body.Pagination) + } + if len(body.Items) != 2 { + t.Fatalf("expected 2 users, got %d", len(body.Items)) } var foundStudent *UserResponse - for i := range body { - if body[i].Email == "student@return.dev" { - foundStudent = &body[i] + for i := range body.Items { + if body.Items[i].Email == "student@return.dev" { + foundStudent = &body.Items[i] } } if foundStudent == nil { @@ -181,7 +184,7 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { }) t.Run("instances include owner and app details", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/instances", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/instances?page=1&limit=10", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -189,15 +192,66 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - var body []InstanceResponse + var body PaginatedInstancesResponse if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode instances: %v", err) } - if len(body) != 1 { - t.Fatalf("expected 1 instance, got %d", len(body)) + if len(body.Items) != 1 || body.Pagination.Total != 1 { + t.Fatalf("unexpected instances response: %+v", body) + } + if body.Items[0].OwnerEmail != "student@return.dev" || body.Items[0].AppHost != "student.rcp.dev" { + t.Fatalf("unexpected instance response: %+v", body.Items[0]) + } + }) + + t.Run("containers include owner details", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/containers?page=1&limit=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body PaginatedContainersResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode containers: %v", err) + } + if len(body.Items) != 1 || body.Pagination.Total != 1 { + t.Fatalf("unexpected containers response: %+v", body) + } + if body.Items[0].OwnerEmail != "student@return.dev" || body.Items[0].Status != "ready" { + t.Fatalf("unexpected container response: %+v", body.Items[0]) + } + }) + + t.Run("user resources include owned resource status", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+student.ID.String()+"/resources", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body UserResourcesResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode resources: %v", err) + } + if body.User.Email != "student@return.dev" { + t.Fatalf("unexpected user: %+v", body.User) + } + if len(body.Instances) != 1 || body.Instances[0].Status != "ACTIVE" { + t.Fatalf("unexpected instance resources: %+v", body.Instances) + } + if len(body.Containers) != 1 || body.Containers[0].Status != "ready" { + t.Fatalf("unexpected container resources: %+v", body.Containers) + } + if len(body.Apps) != 1 || body.Apps[0].Status != "active" { + t.Fatalf("unexpected app resources: %+v", body.Apps) } - if body[0].OwnerEmail != "student@return.dev" || body[0].AppHost != "student.rcp.dev" { - t.Fatalf("unexpected instance response: %+v", body[0]) + if len(body.Keypairs) != 1 || body.Keypairs[0].Status != "registered" { + t.Fatalf("unexpected keypair resources: %+v", body.Keypairs) } }) } diff --git a/internal/domain/admin/repository.go b/internal/domain/admin/repository.go index 58ba5d8..886faec 100644 --- a/internal/domain/admin/repository.go +++ b/internal/domain/admin/repository.go @@ -6,9 +6,13 @@ import ( "entgo.io/ent/dialect/sql" "github.com/KHU-RETURN/rcp-server/ent" + "github.com/KHU-RETURN/rcp-server/ent/app" + "github.com/KHU-RETURN/rcp-server/ent/container" "github.com/KHU-RETURN/rcp-server/ent/instance" + "github.com/KHU-RETURN/rcp-server/ent/keypair" "github.com/KHU-RETURN/rcp-server/ent/user" "github.com/KHU-RETURN/rcp-server/internal/domain/auth" + "github.com/google/uuid" ) type Repository struct { @@ -64,7 +68,11 @@ func (r *Repository) Summary(ctx context.Context) (SummaryResponse, error) { }, nil } -func (r *Repository) Users(ctx context.Context, limit int) ([]UserResponse, error) { +func (r *Repository) Users(ctx context.Context, params PageParams) (PaginatedUsersResponse, error) { + total, err := r.client.User.Query().Count(ctx) + if err != nil { + return PaginatedUsersResponse{}, err + } rows, err := r.client.User.Query(). WithInstances(func(q *ent.InstanceQuery) { q.WithApp() @@ -72,69 +80,252 @@ func (r *Repository) Users(ctx context.Context, limit int) ([]UserResponse, erro WithContainers(). WithKeypairs(). Order(user.ByCreatedAt(sql.OrderDesc()), user.ByEmail()). - Limit(limit). + Offset(pageOffset(params)). + Limit(params.Limit). All(ctx) if err != nil { - return nil, err + return PaginatedUsersResponse{}, err } result := make([]UserResponse, 0, len(rows)) for _, row := range rows { - appCount := 0 - for _, inst := range row.Edges.Instances { - if inst.Edges.App != nil { - appCount++ - } - } - result = append(result, UserResponse{ - ID: row.ID.String(), - Email: row.Email, - Name: row.Name, - Role: auth.RoleForEmail(row.Email), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - InstanceCount: len(row.Edges.Instances), - ContainerCount: len(row.Edges.Containers), - AppCount: appCount, - KeypairCount: len(row.Edges.Keypairs), - }) - } - return result, nil + result = append(result, userResponse(row)) + } + return PaginatedUsersResponse{ + Items: result, + Pagination: paginationResponse(params, total), + }, nil } -func (r *Repository) Instances(ctx context.Context, limit int) ([]InstanceResponse, error) { +func (r *Repository) Instances(ctx context.Context, params PageParams) (PaginatedInstancesResponse, error) { + total, err := r.client.Instance.Query().Count(ctx) + if err != nil { + return PaginatedInstancesResponse{}, err + } rows, err := r.client.Instance.Query(). WithOwner(). WithApp(). Order(instance.ByCreatedAt(sql.OrderDesc()), instance.ByOpenstackID()). - Limit(limit). + Offset(pageOffset(params)). + Limit(params.Limit). All(ctx) if err != nil { - return nil, err + return PaginatedInstancesResponse{}, err } result := make([]InstanceResponse, 0, len(rows)) for _, row := range rows { - owner := row.Edges.Owner - app := row.Edges.App - item := InstanceResponse{ - ID: row.OpenstackID, - Name: row.Name, - Status: row.Status, - FlavorID: row.FlavorID, - ImageID: row.ImageID, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - if owner != nil { - item.OwnerID = owner.ID.String() - item.OwnerEmail = owner.Email - item.OwnerName = owner.Name - } - if app != nil { - item.AppHost = app.Host + result = append(result, instanceResponse(row)) + } + return PaginatedInstancesResponse{ + Items: result, + Pagination: paginationResponse(params, total), + }, nil +} + +func (r *Repository) Containers(ctx context.Context, params PageParams) (PaginatedContainersResponse, error) { + total, err := r.client.Container.Query().Count(ctx) + if err != nil { + return PaginatedContainersResponse{}, err + } + rows, err := r.client.Container.Query(). + WithOwner(). + Order(container.ByCreatedAt(sql.OrderDesc()), container.ByName()). + Offset(pageOffset(params)). + Limit(params.Limit). + All(ctx) + if err != nil { + return PaginatedContainersResponse{}, err + } + + result := make([]ContainerResponse, 0, len(rows)) + for _, row := range rows { + result = append(result, containerResponse(row)) + } + return PaginatedContainersResponse{ + Items: result, + Pagination: paginationResponse(params, total), + }, nil +} + +func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResourcesResponse, error) { + userID, err := uuid.Parse(rawID) + if err != nil { + return UserResourcesResponse{}, err + } + + row, err := r.client.User.Query(). + Where(user.ID(userID)). + WithInstances(func(q *ent.InstanceQuery) { + q.WithApp() + }). + WithContainers(). + WithKeypairs(). + Only(ctx) + if err != nil { + return UserResourcesResponse{}, err + } + + instanceRows, err := r.client.Instance.Query(). + Where(instance.HasOwnerWith(user.ID(userID))). + WithOwner(). + WithApp(). + Order(instance.ByCreatedAt(sql.OrderDesc()), instance.ByOpenstackID()). + All(ctx) + if err != nil { + return UserResourcesResponse{}, err + } + containerRows, err := r.client.Container.Query(). + Where(container.HasOwnerWith(user.ID(userID))). + WithOwner(). + Order(container.ByCreatedAt(sql.OrderDesc()), container.ByName()). + All(ctx) + if err != nil { + return UserResourcesResponse{}, err + } + keypairRows, err := r.client.KeyPair.Query(). + Where(keypair.HasOwnerWith(user.ID(userID))). + WithInstances(). + Order(keypair.ByCreatedAt(sql.OrderDesc()), keypair.ByOpenstackName()). + All(ctx) + if err != nil { + return UserResourcesResponse{}, err + } + appRows, err := r.client.App.Query(). + Where(app.HasInstanceWith(instance.HasOwnerWith(user.ID(userID)))). + WithInstance(). + Order(app.ByCreatedAt(sql.OrderDesc()), app.ByHost()). + All(ctx) + if err != nil { + return UserResourcesResponse{}, err + } + + res := UserResourcesResponse{ + User: userResponse(row), + Instances: make([]InstanceResponse, 0, len(instanceRows)), + Containers: make([]ContainerResponse, 0, len(containerRows)), + Keypairs: make([]KeypairResponse, 0, len(keypairRows)), + Apps: make([]AppResponse, 0, len(appRows)), + } + for _, item := range instanceRows { + res.Instances = append(res.Instances, instanceResponse(item)) + } + for _, item := range containerRows { + res.Containers = append(res.Containers, containerResponse(item)) + } + for _, item := range keypairRows { + res.Keypairs = append(res.Keypairs, keypairResponse(item)) + } + for _, item := range appRows { + res.Apps = append(res.Apps, appResponse(item)) + } + return res, nil +} + +func userResponse(row *ent.User) UserResponse { + appCount := 0 + for _, inst := range row.Edges.Instances { + if inst.Edges.App != nil { + appCount++ } - result = append(result, item) } - return result, nil + return UserResponse{ + ID: row.ID.String(), + Email: row.Email, + Name: row.Name, + Role: auth.RoleForEmail(row.Email), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + InstanceCount: len(row.Edges.Instances), + ContainerCount: len(row.Edges.Containers), + AppCount: appCount, + KeypairCount: len(row.Edges.Keypairs), + } +} + +func instanceResponse(row *ent.Instance) InstanceResponse { + owner := row.Edges.Owner + app := row.Edges.App + item := InstanceResponse{ + ID: row.OpenstackID, + Name: row.Name, + Status: row.Status, + FlavorID: row.FlavorID, + ImageID: row.ImageID, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if owner != nil { + item.OwnerID = owner.ID.String() + item.OwnerEmail = owner.Email + item.OwnerName = owner.Name + } + if app != nil { + item.AppHost = app.Host + } + return item +} + +func containerResponse(row *ent.Container) ContainerResponse { + owner := row.Edges.Owner + item := ContainerResponse{ + ID: row.ID.String(), + Name: row.Name, + Status: "ready", + OpenstackName: row.OpenstackName.String(), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if owner != nil { + item.OwnerID = owner.ID.String() + item.OwnerEmail = owner.Email + item.OwnerName = owner.Name + } + return item +} + +func keypairResponse(row *ent.KeyPair) KeypairResponse { + return KeypairResponse{ + ID: row.ID.String(), + Name: row.OpenstackName, + Status: "registered", + Fingerprint: row.Fingerprint, + SourceType: row.SourceType.String(), + InstanceCount: len(row.Edges.Instances), + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + +func appResponse(row *ent.App) AppResponse { + instanceRow := row.Edges.Instance + res := AppResponse{ + ID: row.ID.String(), + Host: row.Host, + Status: "active", + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if instanceRow != nil { + res.InstanceID = instanceRow.OpenstackID + res.InstanceName = instanceRow.Name + } + return res +} + +func pageOffset(params PageParams) int { + return (params.Page - 1) * params.Limit +} + +func paginationResponse(params PageParams, total int) PaginationResponse { + totalPages := 0 + if total > 0 { + totalPages = (total + params.Limit - 1) / params.Limit + } + return PaginationResponse{ + Page: params.Page, + PerPage: params.Limit, + Total: total, + TotalPages: totalPages, + } } diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go index 3ae2cb6..f9a97b0 100644 --- a/internal/domain/admin/service.go +++ b/internal/domain/admin/service.go @@ -7,7 +7,8 @@ import ( ) const ( - defaultLimit = 100 + defaultPage = 1 + defaultLimit = 10 maxLimit = 500 ) @@ -23,12 +24,20 @@ func (s *Service) Summary(ctx context.Context) (SummaryResponse, error) { return s.repo.Summary(ctx) } -func (s *Service) Users(ctx context.Context, rawLimit string) ([]UserResponse, error) { - return s.repo.Users(ctx, parseLimit(rawLimit)) +func (s *Service) Users(ctx context.Context, rawPage, rawLimit string) (PaginatedUsersResponse, error) { + return s.repo.Users(ctx, parsePageParams(rawPage, rawLimit)) } -func (s *Service) Instances(ctx context.Context, rawLimit string) ([]InstanceResponse, error) { - return s.repo.Instances(ctx, parseLimit(rawLimit)) +func (s *Service) Instances(ctx context.Context, rawPage, rawLimit string) (PaginatedInstancesResponse, error) { + return s.repo.Instances(ctx, parsePageParams(rawPage, rawLimit)) +} + +func (s *Service) Containers(ctx context.Context, rawPage, rawLimit string) (PaginatedContainersResponse, error) { + return s.repo.Containers(ctx, parsePageParams(rawPage, rawLimit)) +} + +func (s *Service) UserResources(ctx context.Context, id string) (UserResourcesResponse, error) { + return s.repo.UserResources(ctx, id) } func (s *Service) System() SystemResponse { @@ -42,13 +51,19 @@ func (s *Service) System() SystemResponse { } } -func parseLimit(raw string) int { - limit, err := strconv.Atoi(raw) - if err != nil || limit <= 0 { - return defaultLimit - } +func parsePageParams(rawPage, rawLimit string) PageParams { + page := parsePositiveInt(rawPage, defaultPage) + limit := parsePositiveInt(rawLimit, defaultLimit) if limit > maxLimit { - return maxLimit + limit = maxLimit + } + return PageParams{Page: page, Limit: limit} +} + +func parsePositiveInt(raw string, fallback int) int { + value, err := strconv.Atoi(raw) + if err != nil || value <= 0 { + return fallback } - return limit + return value } diff --git a/internal/domain/admin/types.go b/internal/domain/admin/types.go index 3ea97ff..6a2f6b1 100644 --- a/internal/domain/admin/types.go +++ b/internal/domain/admin/types.go @@ -24,6 +24,23 @@ type UserResponse struct { KeypairCount int `json:"keypair_count"` } +type PaginationResponse struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` +} + +type PageParams struct { + Page int + Limit int +} + +type PaginatedUsersResponse struct { + Items []UserResponse `json:"items"` + Pagination PaginationResponse `json:"pagination"` +} + type InstanceResponse struct { ID string `json:"id"` Name string `json:"name"` @@ -40,6 +57,57 @@ type InstanceResponse struct { UpdatedAt time.Time `json:"updated_at"` } +type PaginatedInstancesResponse struct { + Items []InstanceResponse `json:"items"` + Pagination PaginationResponse `json:"pagination"` +} + +type ContainerResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + OpenstackName string `json:"openstack_name"` + OwnerID string `json:"owner_id"` + OwnerEmail string `json:"owner_email"` + OwnerName string `json:"owner_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PaginatedContainersResponse struct { + Items []ContainerResponse `json:"items"` + Pagination PaginationResponse `json:"pagination"` +} + +type KeypairResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Fingerprint string `json:"fingerprint"` + SourceType string `json:"source_type"` + InstanceCount int `json:"instance_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AppResponse struct { + ID string `json:"id"` + Host string `json:"host"` + Status string `json:"status"` + InstanceID string `json:"instance_id"` + InstanceName string `json:"instance_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type UserResourcesResponse struct { + User UserResponse `json:"user"` + Instances []InstanceResponse `json:"instances"` + Containers []ContainerResponse `json:"containers"` + Keypairs []KeypairResponse `json:"keypairs"` + Apps []AppResponse `json:"apps"` +} + type SystemResponse struct { APIStatus string `json:"api_status"` OpenStackStatus string `json:"openstack_status"` From 42cfbf6e454d61cc795ac65881aae88284348e4f Mon Sep 17 00:00:00 2001 From: Chae-JS Date: Sat, 6 Jun 2026 16:00:39 +0900 Subject: [PATCH 3/5] feat: add admin resource detail endpoints --- internal/domain/admin/handler.go | 40 +++++++++++++ internal/domain/admin/handler_test.go | 65 ++++++++++++++++++--- internal/domain/admin/repository.go | 81 +++++++++------------------ internal/domain/admin/service.go | 8 +++ internal/domain/admin/types.go | 14 ----- 5 files changed, 132 insertions(+), 76 deletions(-) diff --git a/internal/domain/admin/handler.go b/internal/domain/admin/handler.go index 8037d66..c7d6fbb 100644 --- a/internal/domain/admin/handler.go +++ b/internal/domain/admin/handler.go @@ -25,7 +25,9 @@ func (h *Handler) InitRoutes(rg *gin.RouterGroup) { adminGroup.GET("/users", h.Users) adminGroup.GET("/users/:id/resources", h.UserResources) adminGroup.GET("/instances", h.Instances) + adminGroup.GET("/instances/:id", h.Instance) adminGroup.GET("/containers", h.Containers) + adminGroup.GET("/containers/:id", h.Container) adminGroup.GET("/system", h.System) } } @@ -57,6 +59,25 @@ func (h *Handler) Instances(c *gin.Context) { c.JSON(http.StatusOK, res) } +func (h *Handler) Instance(c *gin.Context) { + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Error: "invalid instance id"}) + return + } + + res, err := h.Svc.Instance(c.Request.Context(), id) + if err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, api.ErrorResponse{Error: "instance not found"}) + return + } + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + func (h *Handler) Containers(c *gin.Context) { res, err := h.Svc.Containers(c.Request.Context(), c.Query("page"), c.Query("limit")) if err != nil { @@ -66,6 +87,25 @@ func (h *Handler) Containers(c *gin.Context) { c.JSON(http.StatusOK, res) } +func (h *Handler) Container(c *gin.Context) { + id := c.Param("id") + if _, err := uuid.Parse(id); err != nil { + c.JSON(http.StatusBadRequest, api.ErrorResponse{Error: "invalid container id"}) + return + } + + res, err := h.Svc.Container(c.Request.Context(), id) + if err != nil { + if ent.IsNotFound(err) { + c.JSON(http.StatusNotFound, api.ErrorResponse{Error: "container not found"}) + return + } + c.JSON(http.StatusInternalServerError, api.ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, res) +} + func (h *Handler) UserResources(c *gin.Context) { id := c.Param("id") if _, err := uuid.Parse(id); err != nil { diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go index b9a8616..b252e31 100644 --- a/internal/domain/admin/handler_test.go +++ b/internal/domain/admin/handler_test.go @@ -129,7 +129,7 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { group.Use(auth.AdminRequired()) Init(client).InitRoutes(group) - t.Run("summary counts users and resources", func(t *testing.T) { + t.Run("summary counts users and visible resources", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/summary", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -142,7 +142,7 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode summary: %v", err) } - if body.Users != 2 || body.Instances != 1 || body.Containers != 1 || body.Apps != 1 || body.Keypairs != 1 { + if body.Users != 2 || body.Instances != 1 || body.Containers != 1 || body.Keypairs != 1 { t.Fatalf("unexpected summary: %+v", body) } if body.StatusCounts["ACTIVE"] != 1 { @@ -178,12 +178,12 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { if foundStudent == nil { t.Fatal("student user missing") } - if foundStudent.InstanceCount != 1 || foundStudent.ContainerCount != 1 || foundStudent.AppCount != 1 || foundStudent.KeypairCount != 1 { + if foundStudent.InstanceCount != 1 || foundStudent.ContainerCount != 1 || foundStudent.KeypairCount != 1 { t.Fatalf("unexpected resource counts: %+v", *foundStudent) } }) - t.Run("instances include owner and app details", func(t *testing.T) { + t.Run("instances include owner details", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/instances?page=1&limit=10", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -199,11 +199,29 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { if len(body.Items) != 1 || body.Pagination.Total != 1 { t.Fatalf("unexpected instances response: %+v", body) } - if body.Items[0].OwnerEmail != "student@return.dev" || body.Items[0].AppHost != "student.rcp.dev" { + if body.Items[0].OwnerEmail != "student@return.dev" { t.Fatalf("unexpected instance response: %+v", body.Items[0]) } }) + t.Run("instance detail includes owner and status", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/instances/vm-001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body InstanceResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode instance detail: %v", err) + } + if body.ID != "vm-001" || body.Status != "ACTIVE" || body.OwnerEmail != "student@return.dev" { + t.Fatalf("unexpected instance detail: %+v", body) + } + }) + t.Run("containers include owner details", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/containers?page=1&limit=10", nil) w := httptest.NewRecorder() @@ -225,7 +243,39 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { } }) - t.Run("user resources include owned resource status", func(t *testing.T) { + t.Run("container detail includes owner and status", func(t *testing.T) { + var containerID string + rows, err := client.Container.Query().All(ctx) + if err != nil { + t.Fatalf("query containers: %v", err) + } + for _, row := range rows { + if row.Name == "student-bucket" { + containerID = row.ID.String() + } + } + if containerID == "" { + t.Fatal("student container missing") + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/containers/"+containerID, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body ContainerResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode container detail: %v", err) + } + if body.ID != containerID || body.Status != "ready" || body.OwnerEmail != "student@return.dev" { + t.Fatalf("unexpected container detail: %+v", body) + } + }) + + t.Run("user resources include owned visible resource status", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/"+student.ID.String()+"/resources", nil) w := httptest.NewRecorder() router.ServeHTTP(w, req) @@ -247,9 +297,6 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { if len(body.Containers) != 1 || body.Containers[0].Status != "ready" { t.Fatalf("unexpected container resources: %+v", body.Containers) } - if len(body.Apps) != 1 || body.Apps[0].Status != "active" { - t.Fatalf("unexpected app resources: %+v", body.Apps) - } if len(body.Keypairs) != 1 || body.Keypairs[0].Status != "registered" { t.Fatalf("unexpected keypair resources: %+v", body.Keypairs) } diff --git a/internal/domain/admin/repository.go b/internal/domain/admin/repository.go index 886faec..73c34b6 100644 --- a/internal/domain/admin/repository.go +++ b/internal/domain/admin/repository.go @@ -6,7 +6,6 @@ import ( "entgo.io/ent/dialect/sql" "github.com/KHU-RETURN/rcp-server/ent" - "github.com/KHU-RETURN/rcp-server/ent/app" "github.com/KHU-RETURN/rcp-server/ent/container" "github.com/KHU-RETURN/rcp-server/ent/instance" "github.com/KHU-RETURN/rcp-server/ent/keypair" @@ -36,10 +35,6 @@ func (r *Repository) Summary(ctx context.Context) (SummaryResponse, error) { if err != nil { return SummaryResponse{}, err } - apps, err := r.client.App.Query().Count(ctx) - if err != nil { - return SummaryResponse{}, err - } keypairs, err := r.client.KeyPair.Query().Count(ctx) if err != nil { return SummaryResponse{}, err @@ -62,7 +57,6 @@ func (r *Repository) Summary(ctx context.Context) (SummaryResponse, error) { Users: users, Instances: instances, Containers: containers, - Apps: apps, Keypairs: keypairs, StatusCounts: statusCounts, }, nil @@ -74,9 +68,7 @@ func (r *Repository) Users(ctx context.Context, params PageParams) (PaginatedUse return PaginatedUsersResponse{}, err } rows, err := r.client.User.Query(). - WithInstances(func(q *ent.InstanceQuery) { - q.WithApp() - }). + WithInstances(). WithContainers(). WithKeypairs(). Order(user.ByCreatedAt(sql.OrderDesc()), user.ByEmail()). @@ -104,7 +96,6 @@ func (r *Repository) Instances(ctx context.Context, params PageParams) (Paginate } rows, err := r.client.Instance.Query(). WithOwner(). - WithApp(). Order(instance.ByCreatedAt(sql.OrderDesc()), instance.ByOpenstackID()). Offset(pageOffset(params)). Limit(params.Limit). @@ -123,6 +114,17 @@ func (r *Repository) Instances(ctx context.Context, params PageParams) (Paginate }, nil } +func (r *Repository) Instance(ctx context.Context, id string) (InstanceResponse, error) { + row, err := r.client.Instance.Query(). + Where(instance.OpenstackID(id)). + WithOwner(). + Only(ctx) + if err != nil { + return InstanceResponse{}, err + } + return instanceResponse(row), nil +} + func (r *Repository) Containers(ctx context.Context, params PageParams) (PaginatedContainersResponse, error) { total, err := r.client.Container.Query().Count(ctx) if err != nil { @@ -148,6 +150,21 @@ func (r *Repository) Containers(ctx context.Context, params PageParams) (Paginat }, nil } +func (r *Repository) Container(ctx context.Context, rawID string) (ContainerResponse, error) { + containerID, err := uuid.Parse(rawID) + if err != nil { + return ContainerResponse{}, err + } + row, err := r.client.Container.Query(). + Where(container.ID(containerID)). + WithOwner(). + Only(ctx) + if err != nil { + return ContainerResponse{}, err + } + return containerResponse(row), nil +} + func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResourcesResponse, error) { userID, err := uuid.Parse(rawID) if err != nil { @@ -156,9 +173,7 @@ func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResou row, err := r.client.User.Query(). Where(user.ID(userID)). - WithInstances(func(q *ent.InstanceQuery) { - q.WithApp() - }). + WithInstances(). WithContainers(). WithKeypairs(). Only(ctx) @@ -169,7 +184,6 @@ func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResou instanceRows, err := r.client.Instance.Query(). Where(instance.HasOwnerWith(user.ID(userID))). WithOwner(). - WithApp(). Order(instance.ByCreatedAt(sql.OrderDesc()), instance.ByOpenstackID()). All(ctx) if err != nil { @@ -191,21 +205,12 @@ func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResou if err != nil { return UserResourcesResponse{}, err } - appRows, err := r.client.App.Query(). - Where(app.HasInstanceWith(instance.HasOwnerWith(user.ID(userID)))). - WithInstance(). - Order(app.ByCreatedAt(sql.OrderDesc()), app.ByHost()). - All(ctx) - if err != nil { - return UserResourcesResponse{}, err - } res := UserResourcesResponse{ User: userResponse(row), Instances: make([]InstanceResponse, 0, len(instanceRows)), Containers: make([]ContainerResponse, 0, len(containerRows)), Keypairs: make([]KeypairResponse, 0, len(keypairRows)), - Apps: make([]AppResponse, 0, len(appRows)), } for _, item := range instanceRows { res.Instances = append(res.Instances, instanceResponse(item)) @@ -216,19 +221,10 @@ func (r *Repository) UserResources(ctx context.Context, rawID string) (UserResou for _, item := range keypairRows { res.Keypairs = append(res.Keypairs, keypairResponse(item)) } - for _, item := range appRows { - res.Apps = append(res.Apps, appResponse(item)) - } return res, nil } func userResponse(row *ent.User) UserResponse { - appCount := 0 - for _, inst := range row.Edges.Instances { - if inst.Edges.App != nil { - appCount++ - } - } return UserResponse{ ID: row.ID.String(), Email: row.Email, @@ -238,14 +234,12 @@ func userResponse(row *ent.User) UserResponse { UpdatedAt: row.UpdatedAt, InstanceCount: len(row.Edges.Instances), ContainerCount: len(row.Edges.Containers), - AppCount: appCount, KeypairCount: len(row.Edges.Keypairs), } } func instanceResponse(row *ent.Instance) InstanceResponse { owner := row.Edges.Owner - app := row.Edges.App item := InstanceResponse{ ID: row.OpenstackID, Name: row.Name, @@ -260,9 +254,6 @@ func instanceResponse(row *ent.Instance) InstanceResponse { item.OwnerEmail = owner.Email item.OwnerName = owner.Name } - if app != nil { - item.AppHost = app.Host - } return item } @@ -297,22 +288,6 @@ func keypairResponse(row *ent.KeyPair) KeypairResponse { } } -func appResponse(row *ent.App) AppResponse { - instanceRow := row.Edges.Instance - res := AppResponse{ - ID: row.ID.String(), - Host: row.Host, - Status: "active", - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - } - if instanceRow != nil { - res.InstanceID = instanceRow.OpenstackID - res.InstanceName = instanceRow.Name - } - return res -} - func pageOffset(params PageParams) int { return (params.Page - 1) * params.Limit } diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go index f9a97b0..30fd280 100644 --- a/internal/domain/admin/service.go +++ b/internal/domain/admin/service.go @@ -32,10 +32,18 @@ func (s *Service) Instances(ctx context.Context, rawPage, rawLimit string) (Pagi return s.repo.Instances(ctx, parsePageParams(rawPage, rawLimit)) } +func (s *Service) Instance(ctx context.Context, id string) (InstanceResponse, error) { + return s.repo.Instance(ctx, id) +} + func (s *Service) Containers(ctx context.Context, rawPage, rawLimit string) (PaginatedContainersResponse, error) { return s.repo.Containers(ctx, parsePageParams(rawPage, rawLimit)) } +func (s *Service) Container(ctx context.Context, id string) (ContainerResponse, error) { + return s.repo.Container(ctx, id) +} + func (s *Service) UserResources(ctx context.Context, id string) (UserResourcesResponse, error) { return s.repo.UserResources(ctx, id) } diff --git a/internal/domain/admin/types.go b/internal/domain/admin/types.go index 6a2f6b1..ec5361b 100644 --- a/internal/domain/admin/types.go +++ b/internal/domain/admin/types.go @@ -6,7 +6,6 @@ type SummaryResponse struct { Users int `json:"users"` Instances int `json:"instances"` Containers int `json:"containers"` - Apps int `json:"apps"` Keypairs int `json:"keypairs"` StatusCounts map[string]int `json:"status_counts"` } @@ -20,7 +19,6 @@ type UserResponse struct { UpdatedAt time.Time `json:"updated_at"` InstanceCount int `json:"instance_count"` ContainerCount int `json:"container_count"` - AppCount int `json:"app_count"` KeypairCount int `json:"keypair_count"` } @@ -52,7 +50,6 @@ type InstanceResponse struct { FlavorName string `json:"flavor_name"` ImageID string `json:"image_id"` FixedIP string `json:"fixed_ip"` - AppHost string `json:"app_host"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -90,22 +87,11 @@ type KeypairResponse struct { UpdatedAt time.Time `json:"updated_at"` } -type AppResponse struct { - ID string `json:"id"` - Host string `json:"host"` - Status string `json:"status"` - InstanceID string `json:"instance_id"` - InstanceName string `json:"instance_name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - type UserResourcesResponse struct { User UserResponse `json:"user"` Instances []InstanceResponse `json:"instances"` Containers []ContainerResponse `json:"containers"` Keypairs []KeypairResponse `json:"keypairs"` - Apps []AppResponse `json:"apps"` } type SystemResponse struct { From d207ce6754b6cc9c969709bfd80def1be2637820 Mon Sep 17 00:00:00 2001 From: Chae-JS Date: Sat, 6 Jun 2026 16:10:23 +0900 Subject: [PATCH 4/5] feat: check real admin system health --- internal/domain/admin/handler.go | 2 +- internal/domain/admin/handler_test.go | 42 ++++++++++ internal/domain/admin/health.go | 108 ++++++++++++++++++++++++++ internal/domain/admin/init.go | 30 ++++++- internal/domain/admin/service.go | 26 +++++-- internal/server/app.go | 2 +- 6 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 internal/domain/admin/health.go diff --git a/internal/domain/admin/handler.go b/internal/domain/admin/handler.go index c7d6fbb..8593c63 100644 --- a/internal/domain/admin/handler.go +++ b/internal/domain/admin/handler.go @@ -126,5 +126,5 @@ func (h *Handler) UserResources(c *gin.Context) { } func (h *Handler) System(c *gin.Context) { - c.JSON(http.StatusOK, h.Svc.System()) + c.JSON(http.StatusOK, h.Svc.System(c.Request.Context())) } diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go index b252e31..d23996d 100644 --- a/internal/domain/admin/handler_test.go +++ b/internal/domain/admin/handler_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -20,6 +21,16 @@ import ( "github.com/KHU-RETURN/rcp-server/internal/domain/auth" ) +type fakeHealthChecker struct { + openstackErr error + storageErr error + sshErr error +} + +func (f fakeHealthChecker) CheckOpenStack(context.Context) error { return f.openstackErr } +func (f fakeHealthChecker) CheckStorage(context.Context) error { return f.storageErr } +func (f fakeHealthChecker) CheckSSHGateway(context.Context) error { return f.sshErr } + func newAdminTestClient(t *testing.T, name string) *ent.Client { t.Helper() @@ -302,3 +313,34 @@ func TestAdminDashboardEndpointsReturnReadOnlyInventory(t *testing.T) { } }) } + +func TestAdminSystemReturnsRealHealthStatuses(t *testing.T) { + gin.SetMode(gin.TestMode) + + client := newAdminTestClient(t, "admin-system") + router := gin.New() + Init(client, WithHealthChecker(fakeHealthChecker{ + openstackErr: nil, + storageErr: errors.New("storage down"), + sshErr: ErrHealthCheckUnconfigured, + })).InitRoutes(router.Group("/api/v1")) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var body SystemResponse + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("decode system: %v", err) + } + if body.APIStatus != "healthy" || body.OpenStackStatus != "healthy" || body.StorageStatus != "unhealthy" || body.SSHGatewayStatus != "unconfigured" { + t.Fatalf("unexpected system statuses: %+v", body) + } + if body.Message == "" { + t.Fatal("expected non-empty system message") + } +} diff --git a/internal/domain/admin/health.go b/internal/domain/admin/health.go new file mode 100644 index 0000000..dc75b2a --- /dev/null +++ b/internal/domain/admin/health.go @@ -0,0 +1,108 @@ +package admin + +import ( + "context" + "errors" + "net" + "time" + + "github.com/gophercloud/gophercloud" + goopenstack "github.com/gophercloud/gophercloud/openstack" + + rcpopenstack "github.com/KHU-RETURN/rcp-server/internal/infrastructure/openstack" +) + +var ErrHealthCheckUnconfigured = errors.New("health check unconfigured") + +type healthChecker interface { + CheckOpenStack(ctx context.Context) error + CheckStorage(ctx context.Context) error + CheckSSHGateway(ctx context.Context) error +} + +type liveHealthChecker struct { + provider *gophercloud.ProviderClient + sshGatewaySock string + timeout time.Duration +} + +func NewLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock string) healthChecker { + return &liveHealthChecker{ + provider: provider, + sshGatewaySock: sshGatewaySock, + timeout: 2 * time.Second, + } +} + +func (c *liveHealthChecker) CheckOpenStack(ctx context.Context) error { + if c == nil || c.provider == nil { + return ErrHealthCheckUnconfigured + } + provider, cancel := c.providerWithContext(ctx) + defer cancel() + + sc, err := goopenstack.NewComputeV2(provider, gophercloud.EndpointOpts{ + Region: rcpopenstack.Region, + }) + if err != nil { + return err + } + resp, err := sc.Get(sc.Endpoint, nil, &gophercloud.RequestOpts{OkCodes: []int{200, 203, 204, 300}}) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + return err +} + +func (c *liveHealthChecker) CheckStorage(ctx context.Context) error { + if c == nil || c.provider == nil { + return ErrHealthCheckUnconfigured + } + provider, cancel := c.providerWithContext(ctx) + defer cancel() + + sc, err := goopenstack.NewObjectStorageV1(provider, gophercloud.EndpointOpts{ + Region: rcpopenstack.Region, + }) + if err != nil { + return err + } + resp, err := sc.Head(sc.Endpoint, &gophercloud.RequestOpts{OkCodes: []int{200, 203, 204, 300}}) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + return err +} + +func (c *liveHealthChecker) CheckSSHGateway(ctx context.Context) error { + if c == nil || c.sshGatewaySock == "" { + return ErrHealthCheckUnconfigured + } + dialer := net.Dialer{Timeout: c.timeout} + conn, err := dialer.DialContext(ctx, "unix", c.sshGatewaySock) + if err != nil { + return err + } + return conn.Close() +} + +func (c *liveHealthChecker) providerWithContext(parent context.Context) (*gophercloud.ProviderClient, context.CancelFunc) { + timeout := c.timeout + if timeout <= 0 { + timeout = 2 * time.Second + } + ctx, cancel := context.WithTimeout(parent, timeout) + provider := *c.provider + provider.Context = ctx + return &provider, cancel +} + +func healthStatus(err error) string { + if err == nil { + return "healthy" + } + if errors.Is(err, ErrHealthCheckUnconfigured) { + return "unconfigured" + } + return "unhealthy" +} diff --git a/internal/domain/admin/init.go b/internal/domain/admin/init.go index 04b04a7..a6e4f9c 100644 --- a/internal/domain/admin/init.go +++ b/internal/domain/admin/init.go @@ -1,9 +1,33 @@ package admin -import "github.com/KHU-RETURN/rcp-server/ent" +import ( + "github.com/gophercloud/gophercloud" -func Init(entClient *ent.Client) *Handler { + "github.com/KHU-RETURN/rcp-server/ent" +) + +type Option func(*options) + +type options struct { + health healthChecker +} + +func WithHealthChecker(health healthChecker) Option { + return func(opts *options) { + opts.health = health + } +} + +func WithLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock string) Option { + return WithHealthChecker(NewLiveHealthChecker(provider, sshGatewaySock)) +} + +func Init(entClient *ent.Client, opts ...Option) *Handler { + cfg := options{} + for _, opt := range opts { + opt(&cfg) + } repo := NewRepository(entClient) - svc := NewService(repo) + svc := NewService(repo, cfg.health) return NewHandler(svc) } diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go index 30fd280..8ed9285 100644 --- a/internal/domain/admin/service.go +++ b/internal/domain/admin/service.go @@ -13,11 +13,12 @@ const ( ) type Service struct { - repo *Repository + repo *Repository + health healthChecker } -func NewService(repo *Repository) *Service { - return &Service{repo: repo} +func NewService(repo *Repository, health healthChecker) *Service { + return &Service{repo: repo, health: health} } func (s *Service) Summary(ctx context.Context) (SummaryResponse, error) { @@ -48,14 +49,23 @@ func (s *Service) UserResources(ctx context.Context, id string) (UserResourcesRe return s.repo.UserResources(ctx, id) } -func (s *Service) System() SystemResponse { +func (s *Service) System(ctx context.Context) SystemResponse { + openstackStatus := "unconfigured" + storageStatus := "unconfigured" + sshGatewayStatus := "unconfigured" + if s.health != nil { + openstackStatus = healthStatus(s.health.CheckOpenStack(ctx)) + storageStatus = healthStatus(s.health.CheckStorage(ctx)) + sshGatewayStatus = healthStatus(s.health.CheckSSHGateway(ctx)) + } + return SystemResponse{ APIStatus: "healthy", - OpenStackStatus: "unknown", - SSHGatewayStatus: "unknown", - StorageStatus: "unknown", + OpenStackStatus: openstackStatus, + SSHGatewayStatus: sshGatewayStatus, + StorageStatus: storageStatus, LastUpdatedAt: time.Now().UTC(), - Message: "System status is limited to API health until provider health checks are wired.", + Message: "System status is checked from the API server against configured providers.", } } diff --git a/internal/server/app.go b/internal/server/app.go index 3125eee..749bed5 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -49,7 +49,7 @@ func NewApp(deps AppDeps) (*App, error) { return &App{ Compute: compute.Init(deps.Provider, deps.EntClient, deps.OpenStackProject, deps.DefaultNetworkID), Access: access.Init(deps.Provider, deps.EntClient, deps.SSHGatewaySecret), - Admin: admin.Init(deps.EntClient), + Admin: admin.Init(deps.EntClient, admin.WithLiveHealthChecker(deps.Provider, deps.SSHGatewaySock)), Apps: apps.Init(deps.EntClient), Auth: authHandler, Storage: storage.Init(deps.Provider, deps.EntClient), From 5fde8dc2b67987dd695587d67ce8ebb0f2ab85a9 Mon Sep 17 00:00:00 2001 From: Chae-JS Date: Sat, 6 Jun 2026 16:19:18 +0900 Subject: [PATCH 5/5] feat: add admin proxy health checks --- cmd/api/main.go | 24 ++++++++++++++ internal/domain/admin/handler_test.go | 13 +++++++- internal/domain/admin/health.go | 45 +++++++++++++++++++++------ internal/domain/admin/init.go | 4 +-- internal/domain/admin/service.go | 6 ++++ internal/domain/admin/types.go | 2 ++ internal/server/app.go | 7 ++++- 7 files changed, 88 insertions(+), 13 deletions(-) diff --git a/cmd/api/main.go b/cmd/api/main.go index 5902256..01a82dd 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -58,7 +58,15 @@ func main() { } notifySock := strings.TrimSpace(os.Getenv("RCP_SSH_GW_NOTIFY_SOCK")) + if notifySock == "" { + notifySock = "/run/rcp/ssh-gateway-notify.sock" + } notifySecret := []byte(strings.TrimSpace(os.Getenv("RCP_SSH_GW_NOTIFY_SECRET"))) + nsProxySock := strings.TrimSpace(os.Getenv("RCP_NS_PROXY_SOCK")) + if nsProxySock == "" { + nsProxySock = "/run/rcp/ns-proxy.sock" + } + httpProxyAddress := resolveAppGatewayAddress(os.Getenv) frontendBaseURL := resolveFrontendBaseURL(os.Getenv) if frontendBaseURL == "" { @@ -74,6 +82,8 @@ func main() { JWTSecret: jwtSecret, SSHGatewaySock: notifySock, SSHGatewaySecret: notifySecret, + NSProxySock: nsProxySock, + HTTPProxyAddress: httpProxyAddress, FrontendBaseURL: frontendBaseURL, }) if err != nil { @@ -101,6 +111,20 @@ func resolveFrontendBaseURL(getenv func(string) string) string { return "" } +func resolveAppGatewayAddress(getenv func(string) string) string { + port := strings.TrimSpace(getenv("APP_GATEWAY_PORT")) + if port == "" { + port = "18080" + } + if strings.HasPrefix(port, ":") { + return "127.0.0.1" + port + } + if strings.Contains(port, ":") { + return port + } + return "127.0.0.1:" + port +} + func resolveDBConfig(getenv func(string) string) (string, string) { driver := strings.TrimSpace(getenv("DB_DRIVER")) if driver == "" { diff --git a/internal/domain/admin/handler_test.go b/internal/domain/admin/handler_test.go index d23996d..6ebe198 100644 --- a/internal/domain/admin/handler_test.go +++ b/internal/domain/admin/handler_test.go @@ -25,11 +25,15 @@ type fakeHealthChecker struct { openstackErr error storageErr error sshErr error + nsErr error + httpErr error } func (f fakeHealthChecker) CheckOpenStack(context.Context) error { return f.openstackErr } func (f fakeHealthChecker) CheckStorage(context.Context) error { return f.storageErr } func (f fakeHealthChecker) CheckSSHGateway(context.Context) error { return f.sshErr } +func (f fakeHealthChecker) CheckNSProxy(context.Context) error { return f.nsErr } +func (f fakeHealthChecker) CheckHTTPProxy(context.Context) error { return f.httpErr } func newAdminTestClient(t *testing.T, name string) *ent.Client { t.Helper() @@ -323,6 +327,8 @@ func TestAdminSystemReturnsRealHealthStatuses(t *testing.T) { openstackErr: nil, storageErr: errors.New("storage down"), sshErr: ErrHealthCheckUnconfigured, + nsErr: nil, + httpErr: errors.New("http proxy down"), })).InitRoutes(router.Group("/api/v1")) req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/system", nil) @@ -337,7 +343,12 @@ func TestAdminSystemReturnsRealHealthStatuses(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { t.Fatalf("decode system: %v", err) } - if body.APIStatus != "healthy" || body.OpenStackStatus != "healthy" || body.StorageStatus != "unhealthy" || body.SSHGatewayStatus != "unconfigured" { + if body.APIStatus != "healthy" || + body.OpenStackStatus != "healthy" || + body.StorageStatus != "unhealthy" || + body.SSHGatewayStatus != "unconfigured" || + body.NSProxyStatus != "healthy" || + body.HTTPProxyStatus != "unhealthy" { t.Fatalf("unexpected system statuses: %+v", body) } if body.Message == "" { diff --git a/internal/domain/admin/health.go b/internal/domain/admin/health.go index dc75b2a..6033f45 100644 --- a/internal/domain/admin/health.go +++ b/internal/domain/admin/health.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net" + "strings" "time" "github.com/gophercloud/gophercloud" @@ -18,19 +19,25 @@ type healthChecker interface { CheckOpenStack(ctx context.Context) error CheckStorage(ctx context.Context) error CheckSSHGateway(ctx context.Context) error + CheckNSProxy(ctx context.Context) error + CheckHTTPProxy(ctx context.Context) error } type liveHealthChecker struct { - provider *gophercloud.ProviderClient - sshGatewaySock string - timeout time.Duration + provider *gophercloud.ProviderClient + sshGatewaySock string + nsProxySock string + httpProxyAddress string + timeout time.Duration } -func NewLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock string) healthChecker { +func NewLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock, nsProxySock, httpProxyAddress string) healthChecker { return &liveHealthChecker{ - provider: provider, - sshGatewaySock: sshGatewaySock, - timeout: 2 * time.Second, + provider: provider, + sshGatewaySock: sshGatewaySock, + nsProxySock: nsProxySock, + httpProxyAddress: httpProxyAddress, + timeout: 2 * time.Second, } } @@ -75,11 +82,31 @@ func (c *liveHealthChecker) CheckStorage(ctx context.Context) error { } func (c *liveHealthChecker) CheckSSHGateway(ctx context.Context) error { - if c == nil || c.sshGatewaySock == "" { + return c.checkUnixSocket(ctx, c.sshGatewaySock) +} + +func (c *liveHealthChecker) CheckNSProxy(ctx context.Context) error { + return c.checkUnixSocket(ctx, c.nsProxySock) +} + +func (c *liveHealthChecker) CheckHTTPProxy(ctx context.Context) error { + if c == nil || strings.TrimSpace(c.httpProxyAddress) == "" { + return ErrHealthCheckUnconfigured + } + dialer := net.Dialer{Timeout: c.timeout} + conn, err := dialer.DialContext(ctx, "tcp", c.httpProxyAddress) + if err != nil { + return err + } + return conn.Close() +} + +func (c *liveHealthChecker) checkUnixSocket(ctx context.Context, path string) error { + if c == nil || strings.TrimSpace(path) == "" { return ErrHealthCheckUnconfigured } dialer := net.Dialer{Timeout: c.timeout} - conn, err := dialer.DialContext(ctx, "unix", c.sshGatewaySock) + conn, err := dialer.DialContext(ctx, "unix", path) if err != nil { return err } diff --git a/internal/domain/admin/init.go b/internal/domain/admin/init.go index a6e4f9c..35e5d50 100644 --- a/internal/domain/admin/init.go +++ b/internal/domain/admin/init.go @@ -18,8 +18,8 @@ func WithHealthChecker(health healthChecker) Option { } } -func WithLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock string) Option { - return WithHealthChecker(NewLiveHealthChecker(provider, sshGatewaySock)) +func WithLiveHealthChecker(provider *gophercloud.ProviderClient, sshGatewaySock, nsProxySock, httpProxyAddress string) Option { + return WithHealthChecker(NewLiveHealthChecker(provider, sshGatewaySock, nsProxySock, httpProxyAddress)) } func Init(entClient *ent.Client, opts ...Option) *Handler { diff --git a/internal/domain/admin/service.go b/internal/domain/admin/service.go index 8ed9285..87dbc2d 100644 --- a/internal/domain/admin/service.go +++ b/internal/domain/admin/service.go @@ -53,16 +53,22 @@ func (s *Service) System(ctx context.Context) SystemResponse { openstackStatus := "unconfigured" storageStatus := "unconfigured" sshGatewayStatus := "unconfigured" + nsProxyStatus := "unconfigured" + httpProxyStatus := "unconfigured" if s.health != nil { openstackStatus = healthStatus(s.health.CheckOpenStack(ctx)) storageStatus = healthStatus(s.health.CheckStorage(ctx)) sshGatewayStatus = healthStatus(s.health.CheckSSHGateway(ctx)) + nsProxyStatus = healthStatus(s.health.CheckNSProxy(ctx)) + httpProxyStatus = healthStatus(s.health.CheckHTTPProxy(ctx)) } return SystemResponse{ APIStatus: "healthy", OpenStackStatus: openstackStatus, SSHGatewayStatus: sshGatewayStatus, + NSProxyStatus: nsProxyStatus, + HTTPProxyStatus: httpProxyStatus, StorageStatus: storageStatus, LastUpdatedAt: time.Now().UTC(), Message: "System status is checked from the API server against configured providers.", diff --git a/internal/domain/admin/types.go b/internal/domain/admin/types.go index ec5361b..96d8939 100644 --- a/internal/domain/admin/types.go +++ b/internal/domain/admin/types.go @@ -98,6 +98,8 @@ type SystemResponse struct { APIStatus string `json:"api_status"` OpenStackStatus string `json:"openstack_status"` SSHGatewayStatus string `json:"ssh_gateway_status"` + NSProxyStatus string `json:"ns_proxy_status"` + HTTPProxyStatus string `json:"http_proxy_status"` StorageStatus string `json:"storage_status"` LastUpdatedAt time.Time `json:"last_updated_at"` Message string `json:"message"` diff --git a/internal/server/app.go b/internal/server/app.go index 749bed5..c8b9793 100644 --- a/internal/server/app.go +++ b/internal/server/app.go @@ -33,6 +33,8 @@ type AppDeps struct { JWTSecret string SSHGatewaySock string SSHGatewaySecret []byte + NSProxySock string + HTTPProxyAddress string FrontendBaseURL string } @@ -49,7 +51,10 @@ func NewApp(deps AppDeps) (*App, error) { return &App{ Compute: compute.Init(deps.Provider, deps.EntClient, deps.OpenStackProject, deps.DefaultNetworkID), Access: access.Init(deps.Provider, deps.EntClient, deps.SSHGatewaySecret), - Admin: admin.Init(deps.EntClient, admin.WithLiveHealthChecker(deps.Provider, deps.SSHGatewaySock)), + Admin: admin.Init( + deps.EntClient, + admin.WithLiveHealthChecker(deps.Provider, deps.SSHGatewaySock, deps.NSProxySock, deps.HTTPProxyAddress), + ), Apps: apps.Init(deps.EntClient), Auth: authHandler, Storage: storage.Init(deps.Provider, deps.EntClient),