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:
Ivan087
2026-05-20 16:56:29 +08:00
parent 8f90cf0f0d
commit 33ddaf97db
59 changed files with 4805 additions and 457 deletions

View File

@ -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