refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO) - Add multi-tenant K8s client with tenant binding and quota management - Add K8s diagnostics client (instance diagnostics) - Add authorization middleware (authz package) - Restructure frontend to feature-based architecture (features/) - Add User Management page in configuration - Add AccessDenied page and route guards - Refactor shared components (form inputs, layout, UI) - Update Tailwind config for new design system - Add comprehensive documentation (docs/, tasks/, plans) - Improve cluster service with better kubeconfig handling - Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
308
backend/internal/domain/service/workspace_service.go
Normal file
308
backend/internal/domain/service/workspace_service.go
Normal file
@ -0,0 +1,308 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
)
|
||||
|
||||
type WorkspaceService struct {
|
||||
workspaceRepo repository.WorkspaceRepository
|
||||
bindingRepo repository.WorkspaceClusterBindingRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
tenantClient repository.TenantKubeClient
|
||||
auditRepo repository.AuditLogRepository
|
||||
}
|
||||
|
||||
func NewWorkspaceService(
|
||||
workspaceRepo repository.WorkspaceRepository,
|
||||
bindingRepo repository.WorkspaceClusterBindingRepository,
|
||||
clusterRepo repository.ClusterRepository,
|
||||
tenantClient repository.TenantKubeClient,
|
||||
auditRepo repository.AuditLogRepository,
|
||||
) *WorkspaceService {
|
||||
return &WorkspaceService{
|
||||
workspaceRepo: workspaceRepo,
|
||||
bindingRepo: bindingRepo,
|
||||
clusterRepo: clusterRepo,
|
||||
tenantClient: tenantClient,
|
||||
auditRepo: auditRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) ListWorkspaces(ctx context.Context) ([]*entity.Workspace, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if principal.IsAdmin() {
|
||||
return s.workspaceRepo.List(ctx)
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*entity.Workspace{workspace}, nil
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) CreateWorkspace(ctx context.Context, name string) (*entity.Workspace, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
workspace := entity.NewWorkspace(name, principal.UserID)
|
||||
workspace.ID = uuid.New().String()
|
||||
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(ctx, principal, "create", "workspace", workspace.ID, workspace.Name, nil)
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) EnsureClusterBinding(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() && workspaceID != principal.WorkspaceID {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if !principal.IsAdmin() && !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
binding := &entity.WorkspaceClusterBinding{
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: workspace.ID,
|
||||
ClusterID: cluster.ID,
|
||||
Namespace: workspace.K8sNamespace,
|
||||
ServiceAccount: workspace.K8sSAName,
|
||||
QuotaCPU: workspace.QuotaCPU,
|
||||
QuotaMemory: workspace.QuotaMemory,
|
||||
QuotaGPU: workspace.QuotaGPU,
|
||||
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||
Status: "active",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||||
if s.tenantClient != nil {
|
||||
if err := s.tenantClient.EnsureTenant(ctx, cluster, tenantBinding); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.bindingRepo.Upsert(ctx, binding); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(ctx, principal, "init", "workspace_cluster_binding", binding.ID, binding.Namespace, map[string]interface{}{"cluster_id": clusterID})
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) IssueKubeconfig(ctx context.Context, workspaceID, clusterID string, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() && workspaceID != principal.WorkspaceID {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if workspace.Status == entity.WorkspaceSuspended {
|
||||
return nil, entity.ErrWorkspaceSuspended
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if !principal.IsAdmin() && !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
binding, err := s.bindingRepo.Get(ctx, workspaceID, clusterID)
|
||||
if err != nil {
|
||||
binding, err = s.EnsureClusterBinding(ctx, workspaceID, clusterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||
tenantBinding.ResourceQuotaHard = resourceQuotaHard(workspace)
|
||||
kubeconfig, err := s.tenantClient.IssueKubeconfig(ctx, cluster, tenantBinding, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.audit(ctx, principal, "issue_kubeconfig", "workspace_cluster_binding", binding.ID, binding.Namespace, map[string]interface{}{"cluster_id": clusterID, "ttl_seconds": int64(entity.TenantTokenTTL(ttl).Seconds())})
|
||||
return kubeconfig, nil
|
||||
}
|
||||
|
||||
func resourceQuotaHard(workspace *entity.Workspace) corev1.ResourceList {
|
||||
hard := corev1.ResourceList{}
|
||||
addQuantity := func(name corev1.ResourceName, value string) {
|
||||
value = normalizeStandardQuotaQuantity(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||
hard[name] = quantity
|
||||
}
|
||||
}
|
||||
addGPUMemoryQuantity := func(value string) {
|
||||
value, err := normalizeGPUMemoryQuota(value)
|
||||
if err != nil || value == "" {
|
||||
return
|
||||
}
|
||||
if quantity, err := resource.ParseQuantity(value); err == nil {
|
||||
hard[corev1.ResourceName("requests.nvidia.com/gpumem")] = quantity
|
||||
}
|
||||
}
|
||||
if workspace == nil {
|
||||
return hard
|
||||
}
|
||||
addQuantity(corev1.ResourceName("requests.cpu"), workspace.QuotaCPU)
|
||||
addQuantity(corev1.ResourceName("requests.memory"), workspace.QuotaMemory)
|
||||
addQuantity(corev1.ResourceName("requests.nvidia.com/gpu"), workspace.QuotaGPU)
|
||||
addGPUMemoryQuantity(workspace.QuotaGPUMem)
|
||||
return hard
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) IssueCurrentKubeconfig(ctx context.Context, requestedClusterID string, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if requestedClusterID != "" {
|
||||
return s.IssueKubeconfig(ctx, principal.WorkspaceID, requestedClusterID, ttl)
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if workspace.DefaultClusterID != "" {
|
||||
return s.IssueKubeconfig(ctx, principal.WorkspaceID, workspace.DefaultClusterID, ttl)
|
||||
}
|
||||
return s.IssueDefaultKubeconfig(ctx, ttl)
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) IssueDefaultKubeconfig(ctx context.Context, ttl time.Duration) (*entity.TenantKubeconfig, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
clusters, err := s.clusterRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
candidates := make([]*entity.Cluster, 0, len(clusters))
|
||||
for _, cluster := range clusters {
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
continue
|
||||
}
|
||||
switch cluster.Visibility {
|
||||
case authz.VisibilityGlobalShared:
|
||||
candidates = append(candidates, cluster)
|
||||
case authz.VisibilityWorkspaceShared:
|
||||
if cluster.WorkspaceID == principal.WorkspaceID {
|
||||
candidates = append(candidates, cluster)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool {
|
||||
leftRank := defaultKubeconfigClusterRank(candidates[i])
|
||||
rightRank := defaultKubeconfigClusterRank(candidates[j])
|
||||
if leftRank != rightRank {
|
||||
return leftRank < rightRank
|
||||
}
|
||||
return candidates[i].Name < candidates[j].Name
|
||||
})
|
||||
var firstIssueErr error
|
||||
for _, cluster := range candidates {
|
||||
if kubeconfig, err := s.IssueKubeconfig(ctx, principal.WorkspaceID, cluster.ID, ttl); err == nil {
|
||||
return kubeconfig, nil
|
||||
} else if firstIssueErr == nil {
|
||||
firstIssueErr = err
|
||||
}
|
||||
}
|
||||
if firstIssueErr != nil {
|
||||
return nil, firstIssueErr
|
||||
}
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
func defaultKubeconfigClusterRank(cluster *entity.Cluster) int {
|
||||
switch cluster.Visibility {
|
||||
case authz.VisibilityGlobalShared:
|
||||
return 0
|
||||
case authz.VisibilityWorkspaceShared:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) SuspendWorkspace(ctx context.Context, workspaceID string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
workspace.Status = entity.WorkspaceSuspended
|
||||
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||
return err
|
||||
}
|
||||
clusters, _ := s.clusterRepo.List(ctx)
|
||||
for _, cluster := range clusters {
|
||||
binding, err := s.bindingRepo.Get(ctx, workspaceID, cluster.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
tenantBinding := entity.NewTenantBinding(binding.Namespace)
|
||||
tenantBinding.ServiceAccountName = binding.ServiceAccount
|
||||
_ = s.tenantClient.SuspendTenant(ctx, cluster, tenantBinding)
|
||||
}
|
||||
s.audit(ctx, principal, "suspend", "workspace", workspace.ID, workspace.Name, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WorkspaceService) audit(ctx context.Context, principal *authz.Principal, action, resourceType, resourceID, resourceName string, details map[string]interface{}) {
|
||||
if s.auditRepo == nil || principal == nil {
|
||||
return
|
||||
}
|
||||
_ = s.auditRepo.Create(ctx, &entity.AuditLog{
|
||||
WorkspaceID: principal.WorkspaceID,
|
||||
UserID: principal.UserID,
|
||||
Action: action,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
ResourceName: resourceName,
|
||||
Details: details,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user