fix: scale replicas in response, K8s metrics client, quota precheck, auth tests
- Add GetMetrics method to MetricsClient interface and implement cluster metrics API - Add QuotaPrecheck service for validating resource quotas before deployment - Add auth DTO with role/permission models and auth handler tests - Add instance diagnostics: mounted NFS volumes, labels, annotations in pod diagnostics - Update workspace handler with GetWorkspace endpoint and shared-user list - Fix monitoring handler to use correct service method name - Add tail_lines fallback in instance handler for snake_case query params - Update nginx config for SSE log streaming support (no buffering) - Add comprehensive test coverage: auth_service_test, auth_handler_test, auth_dto_test, metrics_client_test, quota_precheck_test - Update error messages for quota validation and instance operations - ModifyModal: fix YAML lineWidth:0, modified keys summary, delta-only submit - InstanceCard: correctly disable scale-minus when replicas <= 0 - SidebarLayout: add hover transition for sidebar items - Update todo.md and lessons.md with latest fixes
This commit is contained in:
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -18,6 +19,10 @@ import (
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
workspaceRepo repository.WorkspaceRepository
|
||||
instanceRepo repository.InstanceRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
bindingRepo repository.WorkspaceClusterBindingRepository
|
||||
tenantClient repository.TenantKubeClient
|
||||
passwordHasher PasswordHasher
|
||||
tokenGenerator TokenGenerator
|
||||
}
|
||||
@ -53,6 +58,18 @@ func NewAuthService(
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) SetUserLifecycleCleanup(
|
||||
instanceRepo repository.InstanceRepository,
|
||||
clusterRepo repository.ClusterRepository,
|
||||
bindingRepo repository.WorkspaceClusterBindingRepository,
|
||||
tenantClient repository.TenantKubeClient,
|
||||
) {
|
||||
s.instanceRepo = instanceRepo
|
||||
s.clusterRepo = clusterRepo
|
||||
s.bindingRepo = bindingRepo
|
||||
s.tenantClient = tenantClient
|
||||
}
|
||||
|
||||
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||||
type UserWorkspaceOptions struct {
|
||||
Namespace string
|
||||
@ -87,6 +104,9 @@ func (s *AuthService) Register(ctx context.Context, username, password, role, wo
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if normalizeUserRole(role) == authz.RoleUser {
|
||||
normalizedOpts = defaultUserQuotaOptions(normalizedOpts)
|
||||
}
|
||||
|
||||
// 默认生成占位邮箱,避免数据库约束失败
|
||||
email := username + "@local.ocdp"
|
||||
@ -96,7 +116,7 @@ func (s *AuthService) Register(ctx context.Context, username, password, role, wo
|
||||
user.ID = uuid.New().String()
|
||||
user.Role = normalizeUserRole(role)
|
||||
user.WorkspaceID = workspaceID
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
if user.Role == authz.RoleUser {
|
||||
workspace, err := s.createUserWorkspace(ctx, username, principal.UserID, normalizedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -131,10 +151,7 @@ func (s *AuthService) createUserWorkspace(ctx context.Context, username, created
|
||||
if s.workspaceRepo == nil {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
name := strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||||
workspace := entity.NewWorkspace(name, createdBy)
|
||||
workspace.ID = uuid.New().String()
|
||||
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
name := userWorkspaceName(username)
|
||||
namespace := strings.TrimSpace(opts.Namespace)
|
||||
if namespace == "" {
|
||||
namespace = entity.NamespaceForUser(username)
|
||||
@ -143,6 +160,32 @@ func (s *AuthService) createUserWorkspace(ctx context.Context, username, created
|
||||
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||||
return nil, entity.ErrInvalidNamespace
|
||||
}
|
||||
}
|
||||
if existing, err := s.workspaceRepo.GetByName(ctx, name); err == nil && existing != nil {
|
||||
if namespace != "" && existing.K8sNamespace != namespace {
|
||||
if err := s.ensureNamespaceAvailable(ctx, namespace, existing.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
applyWorkspaceOptions(existing, opts)
|
||||
if namespace != "" {
|
||||
existing.K8sNamespace = namespace
|
||||
existing.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
if err := s.workspaceRepo.Update(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
} else if err != nil && !errors.Is(err, entity.ErrWorkspaceNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureNamespaceAvailable(ctx, namespace, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace := entity.NewWorkspace(name, createdBy)
|
||||
workspace.ID = uuid.New().String()
|
||||
workspace.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
if namespace != "" {
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
@ -151,11 +194,45 @@ func (s *AuthService) createUserWorkspace(ctx context.Context, username, created
|
||||
workspace.QuotaGPU = strings.TrimSpace(opts.QuotaGPU)
|
||||
workspace.QuotaGPUMem = strings.TrimSpace(opts.QuotaGPUMem)
|
||||
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||
if errors.Is(err, entity.ErrWorkspaceExists) {
|
||||
existing, getErr := s.workspaceRepo.GetByName(ctx, name)
|
||||
if getErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing.K8sNamespace != namespace {
|
||||
return nil, entity.ErrWorkspaceNamespaceConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func userWorkspaceName(username string) string {
|
||||
return strings.TrimPrefix(entity.NamespaceForUser(username), "ocdp-u-")
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureNamespaceAvailable(ctx context.Context, namespace, allowedWorkspaceID string) error {
|
||||
if s.workspaceRepo == nil || strings.TrimSpace(namespace) == "" {
|
||||
return nil
|
||||
}
|
||||
workspaces, err := s.workspaceRepo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, workspace := range workspaces {
|
||||
if workspace == nil || workspace.K8sNamespace != namespace {
|
||||
continue
|
||||
}
|
||||
if allowedWorkspaceID != "" && workspace.ID == allowedWorkspaceID {
|
||||
continue
|
||||
}
|
||||
return entity.ErrWorkspaceNamespaceConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, error) {
|
||||
opts.Namespace = strings.TrimSpace(opts.Namespace)
|
||||
opts.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
@ -181,6 +258,16 @@ func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, err
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func defaultUserQuotaOptions(opts UserWorkspaceOptions) UserWorkspaceOptions {
|
||||
if strings.TrimSpace(opts.QuotaGPU) == "" {
|
||||
opts.QuotaGPU = "0"
|
||||
}
|
||||
if strings.TrimSpace(opts.QuotaGPUMem) == "" {
|
||||
opts.QuotaGPUMem = "0"
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (s *AuthService) ListUsers(ctx context.Context) ([]*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
@ -204,25 +291,35 @@ func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID
|
||||
if err != nil {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
previousRole := user.Role
|
||||
if role != "" {
|
||||
user.Role = normalizeUserRole(role)
|
||||
}
|
||||
if workspaceID != "" {
|
||||
if workspaceID != "" && user.Role != authz.RoleUser {
|
||||
user.WorkspaceID = workspaceID
|
||||
}
|
||||
workspaceHandled := false
|
||||
if user.Role == authz.RoleAdmin {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
if user.Role == authz.RoleUser && (role != "" || workspaceID != "" || hasWorkspaceUpdates(opts)) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace, err := s.createUserWorkspace(ctx, user.Username, principal.UserID, normalizedOpts)
|
||||
normalizedOpts = defaultUserQuotaOptions(normalizedOpts)
|
||||
currentWorkspace, _ := s.currentUserWorkspace(ctx, user)
|
||||
if currentWorkspace != nil && shouldCreatePrivateWorkspace(user, previousRole, currentWorkspace) {
|
||||
if normalizedOpts.Namespace == "" || normalizedOpts.Namespace == currentWorkspace.K8sNamespace {
|
||||
normalizedOpts.Namespace = ""
|
||||
}
|
||||
}
|
||||
workspace, err := s.ensureUserWorkspaceForUpdate(ctx, user, previousRole, currentWorkspace, opts, normalizedOpts, principal.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.WorkspaceID = workspace.ID
|
||||
workspaceHandled = true
|
||||
}
|
||||
if isActive != nil {
|
||||
if user.ID == principal.UserID && !*isActive {
|
||||
@ -233,7 +330,7 @@ func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID
|
||||
if mustChangePassword != nil {
|
||||
user.MustChangePassword = *mustChangePassword
|
||||
}
|
||||
if user.Role != authz.RoleAdmin && hasWorkspaceUpdates(opts) {
|
||||
if user.Role != authz.RoleAdmin && !workspaceHandled && hasWorkspaceUpdates(opts) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -242,10 +339,13 @@ func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyWorkspaceOptions(workspace, normalizedOpts)
|
||||
applyWorkspaceOptionsForUpdate(workspace, opts, normalizedOpts)
|
||||
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncWorkspaceBindings(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user.RevokedAfter = time.Now()
|
||||
user.UpdatedAt = time.Now()
|
||||
@ -289,6 +389,115 @@ func applyWorkspaceOptions(workspace *entity.Workspace, opts UserWorkspaceOption
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) currentUserWorkspace(ctx context.Context, user *entity.User) (*entity.Workspace, error) {
|
||||
if s.workspaceRepo == nil || user == nil || user.WorkspaceID == "" {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
return s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||
}
|
||||
|
||||
func shouldCreatePrivateWorkspace(user *entity.User, previousRole string, current *entity.Workspace) bool {
|
||||
if user == nil {
|
||||
return true
|
||||
}
|
||||
if previousRole == authz.RoleAdmin || user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID {
|
||||
return true
|
||||
}
|
||||
if current == nil {
|
||||
return true
|
||||
}
|
||||
return current.Name != userWorkspaceName(user.Username)
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureUserWorkspaceForUpdate(ctx context.Context, user *entity.User, previousRole string, current *entity.Workspace, rawOpts, normalizedOpts UserWorkspaceOptions, createdBy string) (*entity.Workspace, error) {
|
||||
if s.workspaceRepo == nil {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if shouldCreatePrivateWorkspace(user, previousRole, current) {
|
||||
return s.createUserWorkspace(ctx, user.Username, createdBy, normalizedOpts)
|
||||
}
|
||||
if rawNamespace := strings.TrimSpace(rawOpts.Namespace); rawNamespace != "" && rawNamespace != current.K8sNamespace {
|
||||
if err := s.ensureNamespaceAvailable(ctx, rawNamespace, current.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
applyWorkspaceOptionsForUpdate(current, rawOpts, normalizedOpts)
|
||||
if err := s.workspaceRepo.Update(ctx, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.syncWorkspaceBindings(ctx, current); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func applyWorkspaceOptionsForUpdate(workspace *entity.Workspace, rawOpts, normalizedOpts UserWorkspaceOptions) {
|
||||
if namespace := strings.TrimSpace(rawOpts.Namespace); namespace != "" {
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
if strings.TrimSpace(rawOpts.DefaultClusterID) != "" {
|
||||
workspace.DefaultClusterID = normalizedOpts.DefaultClusterID
|
||||
}
|
||||
if strings.TrimSpace(rawOpts.QuotaCPU) != "" {
|
||||
workspace.QuotaCPU = normalizedOpts.QuotaCPU
|
||||
}
|
||||
if strings.TrimSpace(rawOpts.QuotaMemory) != "" {
|
||||
workspace.QuotaMemory = normalizedOpts.QuotaMemory
|
||||
}
|
||||
if strings.TrimSpace(rawOpts.QuotaGPU) != "" {
|
||||
workspace.QuotaGPU = normalizedOpts.QuotaGPU
|
||||
}
|
||||
if strings.TrimSpace(rawOpts.QuotaGPUMem) != "" {
|
||||
workspace.QuotaGPUMem = normalizedOpts.QuotaGPUMem
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) syncWorkspaceBindings(ctx context.Context, workspace *entity.Workspace) error {
|
||||
if workspace == nil || s.bindingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
bindings, err := s.bindingRepo.ListByWorkspace(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding == nil {
|
||||
continue
|
||||
}
|
||||
binding.QuotaCPU = strings.TrimSpace(workspace.QuotaCPU)
|
||||
binding.QuotaMemory = strings.TrimSpace(workspace.QuotaMemory)
|
||||
binding.QuotaGPU = strings.TrimSpace(workspace.QuotaGPU)
|
||||
if binding.QuotaGPU == "" {
|
||||
binding.QuotaGPU = "0"
|
||||
}
|
||||
binding.QuotaGPUMem = strings.TrimSpace(workspace.QuotaGPUMem)
|
||||
if binding.QuotaGPUMem == "" {
|
||||
binding.QuotaGPUMem = "0"
|
||||
}
|
||||
binding.UpdatedAt = time.Now()
|
||||
if s.tenantClient != nil && s.clusterRepo != nil {
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, binding.ClusterID)
|
||||
if err != nil {
|
||||
if errors.Is(err, entity.ErrClusterNotFound) {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||
tenantBinding.ResourceQuotaHard = bindingQuotaHard(binding)
|
||||
if err := s.tenantClient.EnsureTenant(ctx, cluster, tenantBinding); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.bindingRepo.Upsert(ctx, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
@ -300,9 +509,117 @@ func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||||
if userID == principal.UserID {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
if err := s.ensureUserHasNoInstances(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.isExclusiveUserWorkspace(ctx, user) {
|
||||
if err := s.cleanupUserWorkspace(ctx, user.WorkspaceID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.userRepo.Delete(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureUserHasNoInstances(ctx context.Context, user *entity.User) error {
|
||||
if s.instanceRepo == nil || user == nil {
|
||||
return nil
|
||||
}
|
||||
instances, err := s.instanceRepo.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, instance := range instances {
|
||||
if instance == nil {
|
||||
continue
|
||||
}
|
||||
if instance.OwnerID == user.ID {
|
||||
return entity.ErrUserHasInstances
|
||||
}
|
||||
if user.WorkspaceID != "" && user.WorkspaceID != entity.DefaultWorkspaceID && instance.WorkspaceID == user.WorkspaceID {
|
||||
return entity.ErrUserHasInstances
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) isExclusiveUserWorkspace(ctx context.Context, user *entity.User) bool {
|
||||
if user == nil || user.Role == authz.RoleAdmin || user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID {
|
||||
return false
|
||||
}
|
||||
users, err := s.userRepo.List(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, other := range users {
|
||||
if other == nil || other.ID == user.ID {
|
||||
continue
|
||||
}
|
||||
if other.WorkspaceID == user.WorkspaceID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *AuthService) cleanupUserWorkspace(ctx context.Context, workspaceID string) error {
|
||||
if s.workspaceRepo == nil || s.bindingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isProtectedWorkspaceNamespace(workspace.K8sNamespace) {
|
||||
return entity.ErrProtectedNamespace
|
||||
}
|
||||
bindings, err := s.bindingRepo.ListByWorkspace(ctx, workspace.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
if binding == nil {
|
||||
continue
|
||||
}
|
||||
if isProtectedWorkspaceNamespace(binding.Namespace) {
|
||||
return entity.ErrProtectedNamespace
|
||||
}
|
||||
if s.tenantClient != nil && s.clusterRepo != nil {
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, binding.ClusterID)
|
||||
if err != nil && !errors.Is(err, entity.ErrClusterNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||||
if err := s.tenantClient.DeleteTenant(ctx, cluster, tenantBinding); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.bindingRepo.Delete(ctx, binding.WorkspaceID, binding.ClusterID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := s.workspaceRepo.Delete(ctx, workspace.ID); err != nil && !errors.Is(err, entity.ErrWorkspaceNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isProtectedWorkspaceNamespace(namespace string) bool {
|
||||
switch strings.TrimSpace(namespace) {
|
||||
case "", "default", "kube-system", "kube-public", "kube-node-lease":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
if role == authz.RoleAdmin {
|
||||
return authz.RoleAdmin
|
||||
|
||||
Reference in New Issue
Block a user