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:
@ -1,8 +1,8 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArtifactType Artifact 类型
|
||||
@ -16,16 +16,16 @@ const (
|
||||
|
||||
// Artifact OCI Artifact 领域实体
|
||||
type Artifact struct {
|
||||
RegistryID string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Type ArtifactType
|
||||
Size int64
|
||||
MediaType string
|
||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||
Annotations map[string]string
|
||||
CreatedAt time.Time
|
||||
RegistryID string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Type ArtifactType
|
||||
Size int64
|
||||
MediaType string
|
||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||
Annotations map[string]string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Repository 仓库信息
|
||||
@ -50,34 +50,34 @@ func NewArtifact(registryID, repository, tag, digest string) *Artifact {
|
||||
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
||||
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
||||
func (a *Artifact) SetType(mediaType string) {
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
||||
@ -87,85 +87,84 @@ func (a *Artifact) SetType(mediaType string) {
|
||||
// 3. Repository 名称 - charts/ 前缀暗示
|
||||
// 4. MediaType - 兜底判断
|
||||
func (a *Artifact) DetermineType() {
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||
if a.ConfigType != "" {
|
||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||
|
||||
// Helm Chart 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||
) {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
|
||||
// Docker/OCI Image 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"docker.container.image", "oci.image.config",
|
||||
) {
|
||||
a.Type = ArtifactTypeImage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Annotations
|
||||
for key, value := range a.Annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
|
||||
if containsAny(lowerKey, "helm", "chart") ||
|
||||
containsAny(lowerValue, "helm", "chart") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查 Repository 名称(辅助判断)
|
||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||
// charts/ 开头的仓库很可能是 Helm Chart
|
||||
// 但需要结合 MediaType 进一步确认
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||
if a.ConfigType != "" {
|
||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||
|
||||
// Helm Chart 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||
) {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
|
||||
// Docker/OCI Image 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"docker.container.image", "oci.image.config",
|
||||
) {
|
||||
a.Type = ArtifactTypeImage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Annotations
|
||||
for key, value := range a.Annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
|
||||
if containsAny(lowerKey, "helm", "chart") ||
|
||||
containsAny(lowerValue, "helm", "chart") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查 Repository 名称(辅助判断)
|
||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||
// charts/ 开头的仓库很可能是 Helm Chart
|
||||
// 但需要结合 MediaType 进一步确认
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// IsChart 判断是否为 Helm Chart
|
||||
func (a *Artifact) IsChart() bool {
|
||||
return a.Type == ArtifactTypeChart
|
||||
}
|
||||
|
||||
|
||||
@ -6,26 +6,31 @@ import (
|
||||
|
||||
// Cluster Kubernetes 集群领域实体
|
||||
type Cluster struct {
|
||||
ID string
|
||||
Name string
|
||||
Host string // Kubernetes API Server URL
|
||||
CAData string // Base64 encoded CA certificate
|
||||
CertData string // Base64 encoded client certificate
|
||||
KeyData string // Base64 encoded client key
|
||||
Token string // Bearer token (alternative to cert auth)
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
Visibility string
|
||||
Name string
|
||||
Host string // Kubernetes API Server URL
|
||||
CAData string // Base64 encoded CA certificate
|
||||
CertData string // Base64 encoded client certificate
|
||||
KeyData string // Base64 encoded client key
|
||||
Token string // Bearer token (alternative to cert auth)
|
||||
Description string
|
||||
DefaultNamespace string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewCluster 创建新集群
|
||||
func NewCluster(name, host string) *Cluster {
|
||||
now := time.Now()
|
||||
return &Cluster{
|
||||
Name: name,
|
||||
Host: host,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Name: name,
|
||||
Host: host,
|
||||
Visibility: "private",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +68,9 @@ func (c *Cluster) Validate() error {
|
||||
if c.Host == "" {
|
||||
return ErrInvalidClusterHost
|
||||
}
|
||||
if c.Visibility == "" {
|
||||
c.Visibility = "private"
|
||||
}
|
||||
// 必须有认证方式:证书或 Token
|
||||
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
||||
return ErrInvalidClusterAuth
|
||||
@ -100,4 +108,3 @@ users:
|
||||
|
||||
return kubeconfig
|
||||
}
|
||||
|
||||
|
||||
@ -5,11 +5,15 @@ import "errors"
|
||||
// 领域错误定义
|
||||
var (
|
||||
// User errors
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
ErrUnauthorized = errors.New("authentication required")
|
||||
ErrForbidden = errors.New("permission denied")
|
||||
ErrUserInactive = errors.New("user is inactive")
|
||||
ErrWorkspaceSuspended = errors.New("workspace is suspended")
|
||||
|
||||
// Cluster errors
|
||||
ErrInvalidClusterName = errors.New("invalid cluster name")
|
||||
@ -37,4 +41,8 @@ var (
|
||||
ErrArtifactNotFound = errors.New("artifact not found")
|
||||
ErrRepositoryNotFound = errors.New("repository not found")
|
||||
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
||||
|
||||
// Workspace errors
|
||||
ErrWorkspaceNotFound = errors.New("workspace not found")
|
||||
ErrWorkspaceExists = errors.New("workspace already exists")
|
||||
)
|
||||
|
||||
@ -34,6 +34,8 @@ const (
|
||||
// Instance Helm 应用实例领域实体
|
||||
type Instance struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
ClusterID string
|
||||
Name string // Helm Release Name
|
||||
Namespace string
|
||||
|
||||
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
70
backend/internal/domain/entity/instance_diagnostics.go
Normal file
@ -0,0 +1,70 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type InstanceDiagnostics struct {
|
||||
InstanceName string
|
||||
Namespace string
|
||||
Pods []InstancePodDiagnostics
|
||||
Services []InstanceServiceDiagnostics
|
||||
Events []InstanceEventDiagnostics
|
||||
Logs []InstancePodLog
|
||||
CollectedAt time.Time
|
||||
}
|
||||
|
||||
type InstancePodDiagnostics struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Phase string
|
||||
NodeName string
|
||||
PodIP string
|
||||
HostIP string
|
||||
RestartCount int32
|
||||
Containers []InstanceContainerDiagnostics
|
||||
Conditions []InstanceConditionDiagnostics
|
||||
CreationTimestamp time.Time
|
||||
}
|
||||
|
||||
type InstanceContainerDiagnostics struct {
|
||||
Name string
|
||||
Image string
|
||||
Ready bool
|
||||
RestartCount int32
|
||||
State string
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
type InstanceConditionDiagnostics struct {
|
||||
Type string
|
||||
Status string
|
||||
Reason string
|
||||
Message string
|
||||
}
|
||||
|
||||
type InstanceServiceDiagnostics struct {
|
||||
Name string
|
||||
Namespace string
|
||||
Type string
|
||||
ClusterIP string
|
||||
Ports []InstanceEntryPort
|
||||
}
|
||||
|
||||
type InstanceEventDiagnostics struct {
|
||||
Type string
|
||||
Reason string
|
||||
Message string
|
||||
InvolvedKind string
|
||||
InvolvedName string
|
||||
Count int32
|
||||
FirstTimestamp time.Time
|
||||
LastTimestamp time.Time
|
||||
}
|
||||
|
||||
type InstancePodLog struct {
|
||||
Pod string
|
||||
Container string
|
||||
TailLines int64
|
||||
Log string
|
||||
Error string
|
||||
}
|
||||
@ -4,70 +4,70 @@ import "time"
|
||||
|
||||
// ClusterMetrics 集群监控指标
|
||||
type ClusterMetrics struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Status string `json:"status"` // healthy, warning, error, unknown
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"node_count"`
|
||||
PodCount int `json:"pod_count"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Status string `json:"status"` // healthy, warning, error, unknown
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"node_count"`
|
||||
PodCount int `json:"pod_count"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
|
||||
// 集群级别资源汇总
|
||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||
|
||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||
|
||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||
|
||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||
|
||||
// 单机资源最大值
|
||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||
|
||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||
|
||||
// 节点列表(简化信息)
|
||||
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMetrics 节点监控指标
|
||||
type NodeMetrics struct {
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Ready, NotReady
|
||||
Role string `json:"role"` // control-plane, worker
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"pod_count"`
|
||||
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Ready, NotReady
|
||||
Role string `json:"role"` // control-plane, worker
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"pod_count"`
|
||||
|
||||
// CPU 资源
|
||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||
CPUAllocatable string `json:"cpu_allocatable"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||
CPUAllocatable string `json:"cpu_allocatable"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
|
||||
|
||||
// 内存资源
|
||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||
MemoryAllocatable string `json:"memory_allocatable"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
|
||||
|
||||
// GPU 资源(如果有)
|
||||
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
||||
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
||||
GPUPercent float64 `json:"gpu_percent"`
|
||||
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
||||
|
||||
|
||||
// 其他信息
|
||||
OSImage string `json:"os_image,omitempty"`
|
||||
KernelVersion string `json:"kernel_version,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||
OSImage string `json:"os_image,omitempty"`
|
||||
KernelVersion string `json:"kernel_version,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||
}
|
||||
|
||||
// MonitoringSummary 监控汇总
|
||||
@ -80,4 +80,3 @@ type MonitoringSummary struct {
|
||||
TotalPods int `json:"total_pods"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,9 @@ import (
|
||||
// Registry OCI Registry 领域实体
|
||||
type Registry struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
OwnerID string
|
||||
Visibility string
|
||||
Name string
|
||||
URL string
|
||||
Description string
|
||||
@ -21,10 +24,11 @@ type Registry struct {
|
||||
func NewRegistry(name, url string) *Registry {
|
||||
now := time.Now()
|
||||
return &Registry{
|
||||
Name: name,
|
||||
URL: url,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Name: name,
|
||||
URL: url,
|
||||
Visibility: "private",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,6 +59,8 @@ func (r *Registry) Validate() error {
|
||||
if r.URL == "" {
|
||||
return ErrInvalidRegistryURL
|
||||
}
|
||||
if r.Visibility == "" {
|
||||
r.Visibility = "private"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
123
backend/internal/domain/entity/tenant_binding.go
Normal file
123
backend/internal/domain/entity/tenant_binding.go
Normal file
@ -0,0 +1,123 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultTenantServiceAccountName = "tenant-admin"
|
||||
DefaultTenantRoleBindingName = "tenant-admin"
|
||||
DefaultTenantClusterRoleName = "admin"
|
||||
DefaultTenantResourceQuotaName = "tenant-quota"
|
||||
MaxTenantKubeconfigTTL = 2 * time.Hour
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidTenantNamespace = errors.New("invalid tenant namespace")
|
||||
ErrInvalidTenantServiceAccount = errors.New("invalid tenant service account")
|
||||
ErrInvalidTenantRoleBinding = errors.New("invalid tenant role binding")
|
||||
ErrInvalidTenantClusterRole = errors.New("invalid tenant cluster role")
|
||||
ErrInvalidTenantResourceQuota = errors.New("invalid tenant resource quota")
|
||||
ErrInvalidTenantKubeconfigToken = errors.New("invalid tenant kubeconfig token")
|
||||
)
|
||||
|
||||
// TenantBinding describes the Kubernetes resources that grant a workspace access
|
||||
// to one tenant namespace. It intentionally excludes credential material.
|
||||
type TenantBinding struct {
|
||||
Namespace string
|
||||
ServiceAccountName string
|
||||
RoleBindingName string
|
||||
ClusterRoleName string
|
||||
ResourceQuotaName string
|
||||
Labels map[string]string
|
||||
Annotations map[string]string
|
||||
ResourceQuotaHard corev1.ResourceList
|
||||
}
|
||||
|
||||
// TenantKubeconfig contains a short-lived kubeconfig and its expiration time.
|
||||
// Callers must treat Kubeconfig as secret material and must not persist or log it.
|
||||
type TenantKubeconfig struct {
|
||||
Kubeconfig string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// NewTenantBinding returns a tenant binding with production-safe default object names.
|
||||
func NewTenantBinding(namespace string) TenantBinding {
|
||||
return TenantBinding{
|
||||
Namespace: namespace,
|
||||
ServiceAccountName: DefaultTenantServiceAccountName,
|
||||
RoleBindingName: DefaultTenantRoleBindingName,
|
||||
ClusterRoleName: DefaultTenantClusterRoleName,
|
||||
ResourceQuotaName: DefaultTenantResourceQuotaName,
|
||||
Labels: map[string]string{
|
||||
"ocdp.io/managed-by": "ocdp",
|
||||
"ocdp.io/tenant": namespace,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaults fills optional names while preserving explicit caller choices.
|
||||
func (b TenantBinding) WithDefaults() TenantBinding {
|
||||
if b.ServiceAccountName == "" {
|
||||
b.ServiceAccountName = DefaultTenantServiceAccountName
|
||||
}
|
||||
if b.RoleBindingName == "" {
|
||||
b.RoleBindingName = DefaultTenantRoleBindingName
|
||||
}
|
||||
if b.ClusterRoleName == "" {
|
||||
b.ClusterRoleName = DefaultTenantClusterRoleName
|
||||
}
|
||||
if b.ResourceQuotaName == "" {
|
||||
b.ResourceQuotaName = DefaultTenantResourceQuotaName
|
||||
}
|
||||
if b.Labels == nil {
|
||||
b.Labels = map[string]string{}
|
||||
}
|
||||
if b.Labels["ocdp.io/managed-by"] == "" {
|
||||
b.Labels["ocdp.io/managed-by"] = "ocdp"
|
||||
}
|
||||
if b.Namespace != "" && b.Labels["ocdp.io/tenant"] == "" {
|
||||
b.Labels["ocdp.io/tenant"] = b.Namespace
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Validate checks the object names required to provision a tenant namespace.
|
||||
func (b TenantBinding) Validate() error {
|
||||
b = b.WithDefaults()
|
||||
if strings.TrimSpace(b.Namespace) == "" || len(validation.IsDNS1123Label(b.Namespace)) > 0 {
|
||||
return ErrInvalidTenantNamespace
|
||||
}
|
||||
if strings.TrimSpace(b.ServiceAccountName) == "" || len(validation.IsDNS1123Subdomain(b.ServiceAccountName)) > 0 {
|
||||
return ErrInvalidTenantServiceAccount
|
||||
}
|
||||
if strings.TrimSpace(b.RoleBindingName) == "" || len(validation.IsDNS1123Subdomain(b.RoleBindingName)) > 0 {
|
||||
return ErrInvalidTenantRoleBinding
|
||||
}
|
||||
if strings.TrimSpace(b.ClusterRoleName) == "" || len(validation.IsDNS1123Subdomain(b.ClusterRoleName)) > 0 {
|
||||
return ErrInvalidTenantClusterRole
|
||||
}
|
||||
if strings.TrimSpace(b.ResourceQuotaName) == "" || len(validation.IsDNS1123Subdomain(b.ResourceQuotaName)) > 0 {
|
||||
return ErrInvalidTenantResourceQuota
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TenantTokenTTL caps requested kubeconfig lifetimes at MaxTenantKubeconfigTTL.
|
||||
func TenantTokenTTL(requested time.Duration) time.Duration {
|
||||
if requested <= 0 || requested > MaxTenantKubeconfigTTL {
|
||||
return MaxTenantKubeconfigTTL
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func (b TenantBinding) String() string {
|
||||
b = b.WithDefaults()
|
||||
return fmt.Sprintf("tenant namespace %q serviceAccount %q roleBinding %q", b.Namespace, b.ServiceAccountName, b.RoleBindingName)
|
||||
}
|
||||
38
backend/internal/domain/entity/tenant_binding_test.go
Normal file
38
backend/internal/domain/entity/tenant_binding_test.go
Normal file
@ -0,0 +1,38 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTenantTokenTTLCapsAtTwoHours(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
requested time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{name: "uses default for zero", requested: 0, want: MaxTenantKubeconfigTTL},
|
||||
{name: "keeps shorter ttl", requested: 30 * time.Minute, want: 30 * time.Minute},
|
||||
{name: "caps longer ttl", requested: 24 * time.Hour, want: MaxTenantKubeconfigTTL},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
if got := TenantTokenTTL(tc.requested); got != tc.want {
|
||||
t.Fatalf("%s: expected %s, got %s", tc.name, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantBindingWithDefaults(t *testing.T) {
|
||||
binding := NewTenantBinding("tenant-a").WithDefaults()
|
||||
|
||||
if err := binding.Validate(); err != nil {
|
||||
t.Fatalf("expected valid default binding: %v", err)
|
||||
}
|
||||
if binding.ServiceAccountName != DefaultTenantServiceAccountName {
|
||||
t.Fatalf("expected default service account %q, got %q", DefaultTenantServiceAccountName, binding.ServiceAccountName)
|
||||
}
|
||||
if binding.Labels["ocdp.io/tenant"] != "tenant-a" {
|
||||
t.Fatalf("expected tenant label, got %#v", binding.Labels)
|
||||
}
|
||||
}
|
||||
@ -6,13 +6,17 @@ import (
|
||||
|
||||
// User 用户领域实体
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
Email string
|
||||
RevokedAfter time.Time // 全局 Token 撤销时间
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
Email string
|
||||
Role string
|
||||
WorkspaceID string
|
||||
IsActive bool
|
||||
MustChangePassword bool
|
||||
RevokedAfter time.Time // 全局 Token 撤销时间
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewUser 创建新用户
|
||||
@ -22,6 +26,9 @@ func NewUser(username, passwordHash, email string) *User {
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Email: email,
|
||||
Role: "user",
|
||||
WorkspaceID: DefaultWorkspaceID,
|
||||
IsActive: true,
|
||||
RevokedAfter: time.Unix(0, 0), // 初始值:1970-01-01
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@ -49,6 +56,11 @@ func (u *User) Validate() error {
|
||||
if u.PasswordHash == "" {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
if u.Role == "" {
|
||||
u.Role = "user"
|
||||
}
|
||||
if u.WorkspaceID == "" && u.Role != "admin" {
|
||||
u.WorkspaceID = DefaultWorkspaceID
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
150
backend/internal/domain/entity/workspace.go
Normal file
150
backend/internal/domain/entity/workspace.go
Normal file
@ -0,0 +1,150 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultWorkspaceID = "00000000-0000-0000-0000-000000000010"
|
||||
DefaultWorkspaceName = "default"
|
||||
)
|
||||
|
||||
type WorkspaceStatus string
|
||||
|
||||
const (
|
||||
WorkspaceActive WorkspaceStatus = "active"
|
||||
WorkspaceSuspended WorkspaceStatus = "suspended"
|
||||
)
|
||||
|
||||
type Workspace struct {
|
||||
ID string
|
||||
Name string
|
||||
Status WorkspaceStatus
|
||||
K8sNamespace string
|
||||
K8sSAName string
|
||||
DefaultClusterID string
|
||||
QuotaCPU string
|
||||
QuotaMemory string
|
||||
QuotaGPU string
|
||||
QuotaGPUMem string
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewWorkspace(name, createdBy string) *Workspace {
|
||||
now := time.Now()
|
||||
return &Workspace{
|
||||
Name: name,
|
||||
Status: WorkspaceActive,
|
||||
K8sNamespace: NamespaceForWorkspace(name),
|
||||
K8sSAName: ServiceAccountForWorkspace(name),
|
||||
CreatedBy: createdBy,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func NamespaceForWorkspace(name string) string {
|
||||
if name == "" {
|
||||
name = DefaultWorkspaceName
|
||||
}
|
||||
return prefixedDNSLabel("ocdp-ws-", name)
|
||||
}
|
||||
|
||||
func NamespaceForUser(username string) string {
|
||||
if username == "" {
|
||||
username = "user"
|
||||
}
|
||||
return prefixedDNSLabel("ocdp-u-", username)
|
||||
}
|
||||
|
||||
func ServiceAccountForWorkspace(name string) string {
|
||||
if name == "" {
|
||||
name = DefaultWorkspaceName
|
||||
}
|
||||
return prefixedDNSLabel("ocdp-ws-", name)
|
||||
}
|
||||
|
||||
func ServiceAccountForNamespace(namespace string) string {
|
||||
if namespace == "" {
|
||||
namespace = DefaultWorkspaceName
|
||||
}
|
||||
return prefixedDNSLabel("ocdp-sa-", namespace)
|
||||
}
|
||||
|
||||
func prefixedDNSLabel(prefix, value string) string {
|
||||
label := normalizeDNSLabel(value)
|
||||
maxLabelLen := 63 - len(prefix)
|
||||
if maxLabelLen < 1 {
|
||||
maxLabelLen = 1
|
||||
}
|
||||
if len(label) > maxLabelLen {
|
||||
label = strings.Trim(label[:maxLabelLen], "-")
|
||||
}
|
||||
if label == "" {
|
||||
label = DefaultWorkspaceName
|
||||
if len(label) > maxLabelLen {
|
||||
label = label[:maxLabelLen]
|
||||
}
|
||||
}
|
||||
return prefix + label
|
||||
}
|
||||
|
||||
func normalizeDNSLabel(value string) string {
|
||||
out := make([]rune, 0, len(value))
|
||||
lastDash := false
|
||||
for _, r := range value {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
r = r + ('a' - 'A')
|
||||
valid = true
|
||||
}
|
||||
if valid {
|
||||
out = append(out, r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash && len(out) > 0 {
|
||||
out = append(out, '-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
for len(out) > 0 && out[len(out)-1] == '-' {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return DefaultWorkspaceName
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
type WorkspaceClusterBinding struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
ClusterID string
|
||||
Namespace string
|
||||
ServiceAccount string
|
||||
QuotaCPU string
|
||||
QuotaMemory string
|
||||
QuotaGPU string
|
||||
QuotaGPUMem string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
UserID string
|
||||
Action string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
ResourceName string
|
||||
Details map[string]interface{}
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@ -9,20 +9,19 @@ import (
|
||||
type ClusterRepository interface {
|
||||
// Create 创建集群
|
||||
Create(ctx context.Context, cluster *entity.Cluster) error
|
||||
|
||||
|
||||
// GetByID 根据 ID 获取集群
|
||||
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
|
||||
|
||||
|
||||
// GetByName 根据名称获取集群
|
||||
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
|
||||
|
||||
|
||||
// Update 更新集群
|
||||
Update(ctx context.Context, cluster *entity.Cluster) error
|
||||
|
||||
|
||||
// Delete 删除集群
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
|
||||
// List 列出所有集群
|
||||
List(ctx context.Context) ([]*entity.Cluster, error)
|
||||
}
|
||||
|
||||
|
||||
@ -9,26 +9,25 @@ import (
|
||||
type HelmClient interface {
|
||||
// Install 安装 Helm Chart
|
||||
Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||
|
||||
|
||||
// Upgrade 升级 Helm Release
|
||||
Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||
|
||||
|
||||
// Uninstall 卸载 Helm Release
|
||||
Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error
|
||||
|
||||
|
||||
// Rollback 回滚 Helm Release
|
||||
Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error
|
||||
|
||||
|
||||
// GetStatus 获取 Release 状态
|
||||
GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error)
|
||||
|
||||
|
||||
// GetHistory 获取 Release 历史
|
||||
GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error)
|
||||
|
||||
|
||||
// List 列出集群中的所有 Releases
|
||||
List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error)
|
||||
|
||||
|
||||
// GetValues 获取 Release 的 values
|
||||
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
type InstanceDiagnosticsClient interface {
|
||||
GetDiagnostics(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance, tailLines int64) (*entity.InstanceDiagnostics, error)
|
||||
}
|
||||
@ -9,23 +9,22 @@ import (
|
||||
type InstanceRepository interface {
|
||||
// Create 创建实例
|
||||
Create(ctx context.Context, instance *entity.Instance) error
|
||||
|
||||
|
||||
// GetByID 根据 ID 获取实例
|
||||
GetByID(ctx context.Context, id string) (*entity.Instance, error)
|
||||
|
||||
|
||||
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
|
||||
|
||||
|
||||
// Update 更新实例
|
||||
Update(ctx context.Context, instance *entity.Instance) error
|
||||
|
||||
|
||||
// Delete 删除实例
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
|
||||
// ListByCluster 列出指定集群的所有实例
|
||||
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
|
||||
|
||||
|
||||
// List 列出所有实例
|
||||
List(ctx context.Context) ([]*entity.Instance, error)
|
||||
}
|
||||
|
||||
|
||||
@ -10,8 +10,7 @@ import (
|
||||
type MetricsClient interface {
|
||||
// GetClusterMetrics 获取集群的监控指标
|
||||
GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error)
|
||||
|
||||
|
||||
// GetNodeMetrics 获取集群的节点指标
|
||||
GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error)
|
||||
}
|
||||
|
||||
|
||||
@ -7,26 +7,29 @@ import (
|
||||
|
||||
// OCIClient OCI Registry 客户端接口(Output Port)
|
||||
type OCIClient interface {
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error)
|
||||
|
||||
// ListRepositories 列出 Registry 中的 repositories.
|
||||
// artifactType 支持 "chart" 和 "all",默认由调用方决定。
|
||||
ListRepositories(ctx context.Context, registry *entity.Registry, artifactType string) ([]string, error)
|
||||
|
||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||
ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error)
|
||||
|
||||
|
||||
// GetArtifact 获取指定 artifact 的详细信息
|
||||
GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error)
|
||||
|
||||
|
||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||
|
||||
|
||||
// GetValuesYAML 获取 Helm Chart 原始 values.yaml
|
||||
GetValuesYAML(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||
|
||||
// PullArtifact 下载 artifact 到本地
|
||||
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error
|
||||
|
||||
|
||||
// PushArtifact 推送 artifact 到 Registry
|
||||
PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error
|
||||
|
||||
|
||||
// CheckHealth 检查 Registry 健康状态
|
||||
CheckHealth(ctx context.Context, registry *entity.Registry) error
|
||||
}
|
||||
|
||||
|
||||
@ -9,20 +9,19 @@ import (
|
||||
type RegistryRepository interface {
|
||||
// Create 创建 Registry
|
||||
Create(ctx context.Context, registry *entity.Registry) error
|
||||
|
||||
|
||||
// GetByID 根据 ID 获取 Registry
|
||||
GetByID(ctx context.Context, id string) (*entity.Registry, error)
|
||||
|
||||
|
||||
// GetByName 根据名称获取 Registry
|
||||
GetByName(ctx context.Context, name string) (*entity.Registry, error)
|
||||
|
||||
|
||||
// Update 更新 Registry
|
||||
Update(ctx context.Context, registry *entity.Registry) error
|
||||
|
||||
|
||||
// Delete 删除 Registry
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
|
||||
// List 列出所有 Registries
|
||||
List(ctx context.Context) ([]*entity.Registry, error)
|
||||
}
|
||||
|
||||
|
||||
15
backend/internal/domain/repository/tenant_kube_client.go
Normal file
15
backend/internal/domain/repository/tenant_kube_client.go
Normal file
@ -0,0 +1,15 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// TenantKubeClient provisions namespace-scoped Kubernetes access for tenants.
|
||||
type TenantKubeClient interface {
|
||||
EnsureTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error
|
||||
IssueKubeconfig(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding, ttl time.Duration) (*entity.TenantKubeconfig, error)
|
||||
SuspendTenant(ctx context.Context, cluster *entity.Cluster, binding entity.TenantBinding) error
|
||||
}
|
||||
@ -9,20 +9,19 @@ import (
|
||||
type UserRepository interface {
|
||||
// Create 创建用户
|
||||
Create(ctx context.Context, user *entity.User) error
|
||||
|
||||
|
||||
// GetByID 根据 ID 获取用户
|
||||
GetByID(ctx context.Context, id string) (*entity.User, error)
|
||||
|
||||
|
||||
// GetByUsername 根据用户名获取用户
|
||||
GetByUsername(ctx context.Context, username string) (*entity.User, error)
|
||||
|
||||
|
||||
// Update 更新用户
|
||||
Update(ctx context.Context, user *entity.User) error
|
||||
|
||||
|
||||
// Delete 删除用户
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
|
||||
// List 列出所有用户
|
||||
List(ctx context.Context) ([]*entity.User, error)
|
||||
}
|
||||
|
||||
|
||||
26
backend/internal/domain/repository/workspace_repository.go
Normal file
26
backend/internal/domain/repository/workspace_repository.go
Normal file
@ -0,0 +1,26 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
type WorkspaceRepository interface {
|
||||
Create(ctx context.Context, workspace *entity.Workspace) error
|
||||
GetByID(ctx context.Context, id string) (*entity.Workspace, error)
|
||||
GetByName(ctx context.Context, name string) (*entity.Workspace, error)
|
||||
Update(ctx context.Context, workspace *entity.Workspace) error
|
||||
List(ctx context.Context) ([]*entity.Workspace, error)
|
||||
}
|
||||
|
||||
type WorkspaceClusterBindingRepository interface {
|
||||
Upsert(ctx context.Context, binding *entity.WorkspaceClusterBinding) error
|
||||
Get(ctx context.Context, workspaceID, clusterID string) (*entity.WorkspaceClusterBinding, error)
|
||||
Delete(ctx context.Context, workspaceID, clusterID string) error
|
||||
}
|
||||
|
||||
type AuditLogRepository interface {
|
||||
Create(ctx context.Context, log *entity.AuditLog) error
|
||||
ListByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error)
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
)
|
||||
|
||||
// ArtifactService Artifact 浏览领域服务
|
||||
@ -25,22 +26,22 @@ func NewArtifactService(
|
||||
|
||||
// GetRegistry 获取 Registry 信息
|
||||
func (s *ArtifactService) GetRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||
return s.registryRepo.GetByID(ctx, registryID)
|
||||
return s.visibleRegistry(ctx, registryID)
|
||||
}
|
||||
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID string) ([]string, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
// ListRepositories 列出 Registry 中的 repositories
|
||||
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID, artifactType string) ([]string, error) {
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.ListRepositories(ctx, registry)
|
||||
return s.ociClient.ListRepositories(ctx, registry, artifactType)
|
||||
}
|
||||
|
||||
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||
func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
@ -50,7 +51,7 @@ func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, reposit
|
||||
|
||||
// GetArtifact 获取 artifact 详情
|
||||
func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repository, reference string) (*entity.Artifact, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
@ -60,7 +61,7 @@ func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repositor
|
||||
|
||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||
func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repository, reference string) (string, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return "", entity.ErrRegistryNotFound
|
||||
}
|
||||
@ -68,9 +69,19 @@ func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repos
|
||||
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
|
||||
}
|
||||
|
||||
// GetValuesYAML 获取 Helm Chart 的原始 values.yaml
|
||||
func (s *ArtifactService) GetValuesYAML(ctx context.Context, registryID, repository, reference string) (string, error) {
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return "", entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.GetValuesYAML(ctx, registry, repository, reference)
|
||||
}
|
||||
|
||||
// PullArtifact 下载 artifact
|
||||
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
registry, err := s.visibleRegistry(ctx, registryID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
@ -78,3 +89,17 @@ func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, reposito
|
||||
return s.ociClient.PullArtifact(ctx, registry, repository, reference, destPath)
|
||||
}
|
||||
|
||||
func (s *ArtifactService) visibleRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
@ -2,14 +2,22 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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"
|
||||
jwtpkg "github.com/ocdp/cluster-service/internal/pkg/jwt"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
"k8s.io/apimachinery/pkg/util/validation"
|
||||
)
|
||||
|
||||
// AuthService 认证领域服务
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
workspaceRepo repository.WorkspaceRepository
|
||||
passwordHasher PasswordHasher
|
||||
tokenGenerator TokenGenerator
|
||||
}
|
||||
@ -22,27 +30,48 @@ type PasswordHasher interface {
|
||||
|
||||
// TokenGenerator Token 生成器接口
|
||||
type TokenGenerator interface {
|
||||
Generate(userID, username string) (accessToken, refreshToken string, err error)
|
||||
Generate(userID, username, role, workspaceID string) (accessToken, refreshToken string, err error)
|
||||
Verify(token string) (userID, username string, err error)
|
||||
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||||
VerifyAccess(token string) (*jwtpkg.Claims, error)
|
||||
VerifyRefresh(token string) (*jwtpkg.Claims, error)
|
||||
Refresh(refreshToken string) (newAccessToken string, err error)
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
workspaceRepo repository.WorkspaceRepository,
|
||||
passwordHasher PasswordHasher,
|
||||
tokenGenerator TokenGenerator,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
workspaceRepo: workspaceRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
tokenGenerator: tokenGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册新用户(仅需用户名和密码,邮箱将自动补全)
|
||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*entity.User, error) {
|
||||
// Register 注册新用户。业务入口只允许 admin 调用;初始 admin 由 bootstrap seeder 创建。
|
||||
type UserWorkspaceOptions struct {
|
||||
Namespace string
|
||||
DefaultClusterID string
|
||||
QuotaCPU string
|
||||
QuotaMemory string
|
||||
QuotaGPU string
|
||||
QuotaGPUMem string
|
||||
}
|
||||
|
||||
func (s *AuthService) Register(ctx context.Context, username, password, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
|
||||
// 检查用户是否已存在
|
||||
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||||
if existingUser != nil {
|
||||
@ -54,6 +83,10 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认生成占位邮箱,避免数据库约束失败
|
||||
email := username + "@local.ocdp"
|
||||
@ -61,6 +94,27 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
// 创建用户
|
||||
user := entity.NewUser(username, passwordHash, email)
|
||||
user.ID = uuid.New().String()
|
||||
user.Role = normalizeUserRole(role)
|
||||
user.WorkspaceID = workspaceID
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
workspace, err := s.createUserWorkspace(ctx, username, principal.UserID, normalizedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.WorkspaceID = workspace.ID
|
||||
}
|
||||
if user.WorkspaceID == "" {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if user.Role == authz.RoleAdmin {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if isActive != nil {
|
||||
user.IsActive = *isActive
|
||||
}
|
||||
if mustChangePassword != nil {
|
||||
user.MustChangePassword = *mustChangePassword
|
||||
}
|
||||
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
@ -73,31 +127,241 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, err error) {
|
||||
// 查找用户
|
||||
user, err := s.userRepo.GetByUsername(ctx, username)
|
||||
func (s *AuthService) createUserWorkspace(ctx context.Context, username, createdBy string, opts UserWorkspaceOptions) (*entity.Workspace, error) {
|
||||
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)
|
||||
namespace := strings.TrimSpace(opts.Namespace)
|
||||
if namespace == "" {
|
||||
namespace = entity.NamespaceForUser(username)
|
||||
}
|
||||
if namespace != "" {
|
||||
if len(validation.IsDNS1123Label(namespace)) > 0 {
|
||||
return nil, entity.ErrInvalidNamespace
|
||||
}
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
workspace.QuotaCPU = strings.TrimSpace(opts.QuotaCPU)
|
||||
workspace.QuotaMemory = strings.TrimSpace(opts.QuotaMemory)
|
||||
workspace.QuotaGPU = strings.TrimSpace(opts.QuotaGPU)
|
||||
workspace.QuotaGPUMem = strings.TrimSpace(opts.QuotaGPUMem)
|
||||
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
func normalizeQuotaOptions(opts UserWorkspaceOptions) (UserWorkspaceOptions, error) {
|
||||
opts.Namespace = strings.TrimSpace(opts.Namespace)
|
||||
opts.DefaultClusterID = strings.TrimSpace(opts.DefaultClusterID)
|
||||
opts.QuotaCPU = normalizeStandardQuotaQuantity(opts.QuotaCPU)
|
||||
opts.QuotaMemory = normalizeStandardQuotaQuantity(opts.QuotaMemory)
|
||||
opts.QuotaGPU = normalizeStandardQuotaQuantity(opts.QuotaGPU)
|
||||
gpuMem, err := normalizeGPUMemoryQuota(opts.QuotaGPUMem)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
return opts, err
|
||||
}
|
||||
opts.QuotaGPUMem = gpuMem
|
||||
for _, value := range []string{opts.QuotaCPU, opts.QuotaMemory, opts.QuotaGPU} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := resource.ParseQuantity(value); err != nil {
|
||||
return opts, entity.ErrInvalidTenantResourceQuota
|
||||
}
|
||||
}
|
||||
if opts.Namespace != "" && len(validation.IsDNS1123Label(opts.Namespace)) > 0 {
|
||||
return opts, entity.ErrInvalidNamespace
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) ListUsers(ctx context.Context) ([]*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
return s.userRepo.List(ctx)
|
||||
}
|
||||
|
||||
func (s *AuthService) UpdateUser(ctx context.Context, userID, role, workspaceID string, opts UserWorkspaceOptions, isActive, mustChangePassword *bool) (*entity.User, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if role != "" {
|
||||
user.Role = normalizeUserRole(role)
|
||||
}
|
||||
if workspaceID != "" {
|
||||
user.WorkspaceID = workspaceID
|
||||
}
|
||||
if user.Role == authz.RoleAdmin {
|
||||
user.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if user.Role == authz.RoleUser && (user.WorkspaceID == "" || user.WorkspaceID == entity.DefaultWorkspaceID) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace, err := s.createUserWorkspace(ctx, user.Username, principal.UserID, normalizedOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user.WorkspaceID = workspace.ID
|
||||
}
|
||||
if isActive != nil {
|
||||
if user.ID == principal.UserID && !*isActive {
|
||||
return nil, entity.ErrForbidden
|
||||
}
|
||||
user.IsActive = *isActive
|
||||
}
|
||||
if mustChangePassword != nil {
|
||||
user.MustChangePassword = *mustChangePassword
|
||||
}
|
||||
if user.Role != authz.RoleAdmin && hasWorkspaceUpdates(opts) {
|
||||
normalizedOpts, err := normalizeQuotaOptions(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyWorkspaceOptions(workspace, normalizedOpts)
|
||||
if err := s.workspaceRepo.Update(ctx, workspace); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
user.RevokedAfter = time.Now()
|
||||
user.UpdatedAt = time.Now()
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func hasWorkspaceUpdates(opts UserWorkspaceOptions) bool {
|
||||
return strings.TrimSpace(opts.Namespace) != "" ||
|
||||
strings.TrimSpace(opts.DefaultClusterID) != "" ||
|
||||
strings.TrimSpace(opts.QuotaCPU) != "" ||
|
||||
strings.TrimSpace(opts.QuotaMemory) != "" ||
|
||||
strings.TrimSpace(opts.QuotaGPU) != "" ||
|
||||
strings.TrimSpace(opts.QuotaGPUMem) != ""
|
||||
}
|
||||
|
||||
func applyWorkspaceOptions(workspace *entity.Workspace, opts UserWorkspaceOptions) {
|
||||
if namespace := strings.TrimSpace(opts.Namespace); namespace != "" {
|
||||
workspace.K8sNamespace = namespace
|
||||
workspace.K8sSAName = entity.ServiceAccountForNamespace(namespace)
|
||||
}
|
||||
if value := strings.TrimSpace(opts.DefaultClusterID); value != "" {
|
||||
workspace.DefaultClusterID = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaCPU); value != "" {
|
||||
workspace.QuotaCPU = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaMemory); value != "" {
|
||||
workspace.QuotaMemory = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaGPU); value != "" {
|
||||
workspace.QuotaGPU = value
|
||||
}
|
||||
if value := strings.TrimSpace(opts.QuotaGPUMem); value != "" {
|
||||
workspace.QuotaGPUMem = value
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) DeleteUser(ctx context.Context, userID string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
if !principal.IsAdmin() {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
if userID == principal.UserID {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
return s.userRepo.Delete(ctx, userID)
|
||||
}
|
||||
|
||||
func normalizeUserRole(role string) string {
|
||||
if role == authz.RoleAdmin {
|
||||
return authz.RoleAdmin
|
||||
}
|
||||
return authz.RoleUser
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, user *entity.User, err error) {
|
||||
// 查找用户
|
||||
user, err = s.userRepo.GetByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", "", nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", "", nil, entity.ErrUserInactive
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||||
return "", "", entity.ErrInvalidPassword
|
||||
return "", "", nil, entity.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
|
||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return "", "", nil, err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
return accessToken, refreshToken, user, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
|
||||
return s.tokenGenerator.Refresh(refreshToken)
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, *entity.User, error) {
|
||||
claims, err := s.tokenGenerator.VerifyRefresh(refreshToken)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return "", nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return "", nil, entity.ErrUserInactive
|
||||
}
|
||||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||
return "", nil, entity.ErrTokenRevoked
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
accessToken, _, err := s.tokenGenerator.Generate(user.ID, user.Username, user.Role, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return accessToken, user, nil
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
@ -106,25 +370,84 @@ func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User,
|
||||
}
|
||||
|
||||
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
|
||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (*authz.Principal, error) {
|
||||
// 1. JWT 自验证
|
||||
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
|
||||
claims, err := s.tokenGenerator.VerifyAccess(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 检查用户级别的撤销时间
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
user, err := s.userRepo.GetByID(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if !user.IsActive {
|
||||
return nil, entity.ErrUserInactive
|
||||
}
|
||||
|
||||
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||||
if issuedAt < user.RevokedAfter.Unix() {
|
||||
return "", "", entity.ErrTokenRevoked
|
||||
if claims.IssuedAt == nil || claims.IssuedAt.Unix() < user.RevokedAfter.Unix() {
|
||||
return nil, entity.ErrTokenRevoked
|
||||
}
|
||||
if err := s.ensureWorkspaceActive(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
workspaceName := ""
|
||||
namespace := ""
|
||||
defaultClusterID := ""
|
||||
quotaCPU := ""
|
||||
quotaMemory := ""
|
||||
quotaGPU := ""
|
||||
quotaGPUMem := ""
|
||||
if s.workspaceRepo != nil && user.WorkspaceID != "" {
|
||||
if workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID); err == nil && workspace != nil {
|
||||
workspaceName = workspace.Name
|
||||
namespace = workspace.K8sNamespace
|
||||
defaultClusterID = workspace.DefaultClusterID
|
||||
quotaCPU = workspace.QuotaCPU
|
||||
quotaMemory = workspace.QuotaMemory
|
||||
quotaGPU = workspace.QuotaGPU
|
||||
quotaGPUMem = workspace.QuotaGPUMem
|
||||
}
|
||||
}
|
||||
|
||||
return userID, username, nil
|
||||
return &authz.Principal{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
WorkspaceID: user.WorkspaceID,
|
||||
WorkspaceName: workspaceName,
|
||||
Namespace: namespace,
|
||||
DefaultClusterID: defaultClusterID,
|
||||
QuotaCPU: quotaCPU,
|
||||
QuotaMemory: quotaMemory,
|
||||
QuotaGPU: quotaGPU,
|
||||
QuotaGPUMem: quotaGPUMem,
|
||||
Permissions: authz.PermissionsForRole(user.Role),
|
||||
PermissionVersion: 1,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) GetWorkspaceByID(ctx context.Context, id string) (*entity.Workspace, error) {
|
||||
if s.workspaceRepo == nil || id == "" {
|
||||
return nil, entity.ErrWorkspaceNotFound
|
||||
}
|
||||
return s.workspaceRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *AuthService) ensureWorkspaceActive(ctx context.Context, user *entity.User) error {
|
||||
if user.Role == authz.RoleAdmin || user.WorkspaceID == "" || s.workspaceRepo == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, user.WorkspaceID)
|
||||
if err != nil {
|
||||
return entity.ErrWorkspaceNotFound
|
||||
}
|
||||
if workspace.Status == entity.WorkspaceSuspended {
|
||||
return entity.ErrWorkspaceSuspended
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(会触发全局登出)
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// ClusterService 集群管理领域服务
|
||||
@ -21,8 +22,21 @@ func NewClusterService(clusterRepo repository.ClusterRepository) *ClusterService
|
||||
|
||||
// CreateCluster 创建新集群
|
||||
func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 生成 ID
|
||||
cluster.ID = uuid.New().String()
|
||||
cluster.OwnerID = principal.UserID
|
||||
cluster.WorkspaceID = principal.WorkspaceID
|
||||
if principal.IsAdmin() && cluster.WorkspaceID == "" {
|
||||
cluster.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if !principal.IsAdmin() && cluster.Visibility == authz.VisibilityGlobalShared {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
cluster.Visibility = authz.NormalizeVisibility(principal.Role, cluster.Visibility)
|
||||
|
||||
// 验证
|
||||
if err := cluster.Validate(); err != nil {
|
||||
@ -30,9 +44,11 @@ func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Clus
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
existingCluster, _ := s.clusterRepo.GetByName(ctx, cluster.Name)
|
||||
if existingCluster != nil {
|
||||
return entity.ErrClusterExists
|
||||
clusters, _ := s.clusterRepo.List(ctx)
|
||||
for _, existingCluster := range clusters {
|
||||
if existingCluster.Name == cluster.Name && existingCluster.WorkspaceID == cluster.WorkspaceID && existingCluster.OwnerID == cluster.OwnerID {
|
||||
return entity.ErrClusterExists
|
||||
}
|
||||
}
|
||||
|
||||
return s.clusterRepo.Create(ctx, cluster)
|
||||
@ -40,16 +56,41 @@ func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Clus
|
||||
|
||||
// GetCluster 获取集群
|
||||
func (s *ClusterService) GetCluster(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
return s.clusterRepo.GetByID(ctx, id)
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// UpdateCluster 更新集群
|
||||
func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
||||
existing, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanWriteResource(principal, existing.WorkspaceID, existing.OwnerID, existing.Visibility) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
cluster.WorkspaceID = existing.WorkspaceID
|
||||
cluster.OwnerID = existing.OwnerID
|
||||
if principal.IsAdmin() {
|
||||
cluster.Visibility = authz.NormalizeVisibility(principal.Role, cluster.Visibility)
|
||||
} else {
|
||||
cluster.Visibility = existing.Visibility
|
||||
}
|
||||
|
||||
// 验证
|
||||
if err := cluster.Validate(); err != nil {
|
||||
@ -61,17 +102,37 @@ func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Clus
|
||||
|
||||
// DeleteCluster 删除集群
|
||||
func (s *ClusterService) DeleteCluster(ctx context.Context, id string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, id)
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanWriteResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
|
||||
return s.clusterRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ListClusters 列出所有集群
|
||||
func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, error) {
|
||||
return s.clusterRepo.List(ctx)
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
clusters, err := s.clusterRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]*entity.Cluster, 0, len(clusters))
|
||||
for _, cluster := range clusters {
|
||||
if authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
visible = append(visible, cluster)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
|
||||
@ -11,16 +11,23 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// InstanceService Helm 实例管理领域服务
|
||||
type InstanceService struct {
|
||||
instanceRepo repository.InstanceRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
registryRepo repository.RegistryRepository
|
||||
helmClient repository.HelmClient
|
||||
ociClient repository.OCIClient
|
||||
entryClient repository.InstanceEntryClient
|
||||
instanceRepo repository.InstanceRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
registryRepo repository.RegistryRepository
|
||||
bindingRepo repository.WorkspaceClusterBindingRepository
|
||||
helmClient repository.HelmClient
|
||||
ociClient repository.OCIClient
|
||||
entryClient repository.InstanceEntryClient
|
||||
diagClient repository.InstanceDiagnosticsClient
|
||||
workspaceRepo repository.WorkspaceRepository
|
||||
tenantClient repository.TenantKubeClient
|
||||
}
|
||||
|
||||
// NewInstanceService 创建实例服务
|
||||
@ -31,17 +38,32 @@ func NewInstanceService(
|
||||
helmClient repository.HelmClient,
|
||||
ociClient repository.OCIClient,
|
||||
entryClient repository.InstanceEntryClient,
|
||||
bindingRepo ...repository.WorkspaceClusterBindingRepository,
|
||||
) *InstanceService {
|
||||
var workspaceBindingRepo repository.WorkspaceClusterBindingRepository
|
||||
if len(bindingRepo) > 0 {
|
||||
workspaceBindingRepo = bindingRepo[0]
|
||||
}
|
||||
return &InstanceService{
|
||||
instanceRepo: instanceRepo,
|
||||
clusterRepo: clusterRepo,
|
||||
registryRepo: registryRepo,
|
||||
bindingRepo: workspaceBindingRepo,
|
||||
helmClient: helmClient,
|
||||
ociClient: ociClient,
|
||||
entryClient: entryClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InstanceService) SetDiagnosticsClient(client repository.InstanceDiagnosticsClient) {
|
||||
s.diagClient = client
|
||||
}
|
||||
|
||||
func (s *InstanceService) SetTenantProvisioning(workspaceRepo repository.WorkspaceRepository, tenantClient repository.TenantKubeClient) {
|
||||
s.workspaceRepo = workspaceRepo
|
||||
s.tenantClient = tenantClient
|
||||
}
|
||||
|
||||
const chartCacheDir = "/tmp/charts"
|
||||
|
||||
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
||||
@ -62,8 +84,14 @@ func (s *InstanceService) downloadChart(ctx context.Context, registry *entity.Re
|
||||
|
||||
// CreateInstance 创建(安装)新实例
|
||||
func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 生成 ID
|
||||
instance.ID = uuid.New().String()
|
||||
instance.WorkspaceID = principal.WorkspaceID
|
||||
instance.OwnerID = principal.UserID
|
||||
|
||||
// 验证
|
||||
if err := instance.Validate(); err != nil {
|
||||
@ -75,12 +103,25 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 检查 Registry 是否存在
|
||||
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
if err := s.applyNamespacePolicy(ctx, principal, cluster, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
enforceNamespaceValues(instance)
|
||||
if err := s.ensureTenantForInstance(ctx, principal, cluster, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查实例是否已存在
|
||||
existingInstance, _ := s.instanceRepo.GetByClusterAndName(ctx, instance.ClusterID, instance.Name)
|
||||
@ -111,13 +152,24 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
|
||||
|
||||
// GetInstance 获取实例
|
||||
func (s *InstanceService) GetInstance(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
return s.instanceRepo.GetByID(ctx, id)
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !s.canReadInstance(principal, instance) {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetInstanceStatus 获取实例实时状态
|
||||
func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
// 从数据库获取基本信息
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
instance, err := s.GetInstance(ctx, id)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
@ -143,11 +195,20 @@ func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*en
|
||||
|
||||
// UpdateInstance 更新(升级)实例
|
||||
func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查实例是否存在
|
||||
existingInstance, err := s.instanceRepo.GetByID(ctx, instance.ID)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
if !s.canWriteInstance(principal, existingInstance) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
instance.WorkspaceID = existingInstance.WorkspaceID
|
||||
instance.OwnerID = existingInstance.OwnerID
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, existingInstance.ClusterID)
|
||||
@ -161,6 +222,8 @@ func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.I
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
instance.Namespace = existingInstance.Namespace
|
||||
enforceNamespaceValues(instance)
|
||||
instance.BeginOperation(entity.OperationUpgrade, "Pending upgrade")
|
||||
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||
return err
|
||||
@ -182,11 +245,18 @@ func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.I
|
||||
|
||||
// DeleteInstance 删除(卸载)实例
|
||||
func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
if !s.canWriteInstance(principal, instance) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
@ -208,11 +278,18 @@ func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
||||
|
||||
// RollbackInstance 回滚实例
|
||||
func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revision int) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
if !s.canWriteInstance(principal, instance) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
@ -235,7 +312,7 @@ func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revis
|
||||
// GetInstanceHistory 获取实例历史
|
||||
func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]*entity.ReleaseHistory, error) {
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
instance, err := s.GetInstance(ctx, id)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
@ -252,18 +329,35 @@ func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]
|
||||
|
||||
// ListInstancesByCluster 列出集群的所有实例
|
||||
func (s *InstanceService) ListInstancesByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
// 检查集群是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return s.instanceRepo.ListByCluster(ctx, clusterID)
|
||||
instances, err := s.instanceRepo.ListByCluster(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]*entity.Instance, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
if s.canReadInstance(principal, instance) {
|
||||
visible = append(visible, instance)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// ListInstanceEntries 列出实例关联的入口信息(Service / Ingress)
|
||||
func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, instanceID string) ([]*entity.InstanceEntry, error) {
|
||||
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
instance, err := s.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
@ -283,6 +377,187 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in
|
||||
return s.entryClient.ListEntries(ctx, cluster, instance)
|
||||
}
|
||||
|
||||
func (s *InstanceService) GetInstanceDiagnostics(ctx context.Context, clusterID, instanceID string, tailLines int64) (*entity.InstanceDiagnostics, error) {
|
||||
instance, err := s.GetInstance(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
if instance.ClusterID != clusterID {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if s.diagClient == nil {
|
||||
return nil, fmt.Errorf("instance diagnostics client is not configured")
|
||||
}
|
||||
return s.diagClient.GetDiagnostics(ctx, cluster, instance, tailLines)
|
||||
}
|
||||
|
||||
func (s *InstanceService) canReadInstance(principal *authz.Principal, instance *entity.Instance) bool {
|
||||
if principal.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
return instance.WorkspaceID == principal.WorkspaceID && instance.OwnerID == principal.UserID
|
||||
}
|
||||
|
||||
func (s *InstanceService) canWriteInstance(principal *authz.Principal, instance *entity.Instance) bool {
|
||||
if principal.IsAdmin() {
|
||||
return true
|
||||
}
|
||||
return instance.WorkspaceID == principal.WorkspaceID && instance.OwnerID == principal.UserID
|
||||
}
|
||||
|
||||
func enforceNamespaceValues(instance *entity.Instance) {
|
||||
if instance == nil || instance.Namespace == "" {
|
||||
return
|
||||
}
|
||||
if instance.Values == nil {
|
||||
instance.Values = map[string]interface{}{}
|
||||
}
|
||||
instance.Values["namespace"] = instance.Namespace
|
||||
setExistingStringValue(instance.Values, "namespaceOverride", instance.Namespace)
|
||||
setExistingStringValue(instance.Values, "targetNamespace", instance.Namespace)
|
||||
setExistingNestedStringValue(instance.Values, "global", "namespace", instance.Namespace)
|
||||
setExistingNestedStringValue(instance.Values, "global", "namespaceOverride", instance.Namespace)
|
||||
}
|
||||
|
||||
func setExistingStringValue(values map[string]interface{}, key, namespace string) {
|
||||
if _, ok := values[key]; ok {
|
||||
values[key] = namespace
|
||||
}
|
||||
}
|
||||
|
||||
func setExistingNestedStringValue(values map[string]interface{}, parent, key, namespace string) {
|
||||
child, ok := values[parent].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, ok := child[key]; ok {
|
||||
child[key] = namespace
|
||||
}
|
||||
}
|
||||
|
||||
func (s *InstanceService) applyNamespacePolicy(ctx context.Context, principal *authz.Principal, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
if principal.IsAdmin() {
|
||||
if isProtectedSystemNamespace(instance.Namespace) {
|
||||
return entity.ErrInvalidNamespace
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if isReservedNamespace(instance.Namespace) {
|
||||
return entity.ErrInvalidNamespace
|
||||
}
|
||||
if cluster.Visibility != authz.VisibilityPrivate || cluster.OwnerID != principal.UserID {
|
||||
namespace := principal.Namespace
|
||||
if namespace == "" {
|
||||
namespace = entity.NamespaceForWorkspace(principal.WorkspaceName)
|
||||
}
|
||||
if s.bindingRepo != nil {
|
||||
if binding, err := s.bindingRepo.Get(ctx, principal.WorkspaceID, cluster.ID); err == nil && binding != nil && binding.Namespace != "" {
|
||||
namespace = binding.Namespace
|
||||
}
|
||||
}
|
||||
instance.Namespace = namespace
|
||||
return nil
|
||||
}
|
||||
if instance.Namespace == "" {
|
||||
if cluster.DefaultNamespace != "" {
|
||||
instance.Namespace = cluster.DefaultNamespace
|
||||
} else if principal.Namespace != "" {
|
||||
instance.Namespace = principal.Namespace
|
||||
} else {
|
||||
instance.Namespace = entity.NamespaceForWorkspace(principal.Username)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *InstanceService) ensureTenantForInstance(ctx context.Context, principal *authz.Principal, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
if principal.IsAdmin() || s.workspaceRepo == nil || s.tenantClient == nil {
|
||||
return nil
|
||||
}
|
||||
workspace, err := s.workspaceRepo.GetByID(ctx, principal.WorkspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if workspace.Status == entity.WorkspaceSuspended {
|
||||
return entity.ErrWorkspaceSuspended
|
||||
}
|
||||
binding := entity.NewTenantBinding(instance.Namespace)
|
||||
binding.ServiceAccountName = workspace.K8sSAName
|
||||
binding.ResourceQuotaHard = instanceResourceQuotaHard(workspace)
|
||||
if err := s.tenantClient.EnsureTenant(ctx, cluster, binding); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.bindingRepo != nil {
|
||||
_ = s.bindingRepo.Upsert(ctx, &entity.WorkspaceClusterBinding{
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: workspace.ID,
|
||||
ClusterID: cluster.ID,
|
||||
Namespace: instance.Namespace,
|
||||
ServiceAccount: workspace.K8sSAName,
|
||||
QuotaCPU: workspace.QuotaCPU,
|
||||
QuotaMemory: workspace.QuotaMemory,
|
||||
QuotaGPU: workspace.QuotaGPU,
|
||||
QuotaGPUMem: workspace.QuotaGPUMem,
|
||||
Status: "active",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func instanceResourceQuotaHard(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 isReservedNamespace(namespace string) bool {
|
||||
switch namespace {
|
||||
case "default", "kube-system", "kube-public", "kube-node-lease":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isProtectedSystemNamespace(namespace string) bool {
|
||||
switch namespace {
|
||||
case "kube-system", "kube-public", "kube-node-lease":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// executeAndSyncInstall 异步执行安装并监控状态
|
||||
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||
// 执行 Helm 安装
|
||||
@ -338,7 +613,7 @@ func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID
|
||||
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
|
||||
// 执行 Helm 卸载
|
||||
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
|
||||
|
||||
|
||||
// 获取实例
|
||||
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if getErr != nil {
|
||||
@ -360,7 +635,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
|
||||
// 卸载成功,标记为已卸载
|
||||
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
|
||||
|
||||
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
|
||||
time.Sleep(3 * time.Second)
|
||||
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||
@ -377,7 +652,7 @@ func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceI
|
||||
|
||||
// syncInstanceStatus 同步实例状态(定期检查 Helm 状态并更新数据库)
|
||||
func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, operation entity.InstanceOperation) {
|
||||
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
||||
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
||||
interval := 10 * time.Second // 每10秒检查一次
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
|
||||
@ -4,21 +4,26 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
persistencemock "github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
)
|
||||
|
||||
func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
principal := &authz.Principal{UserID: "user-1", Username: "tester", Role: authz.RoleUser, WorkspaceID: entity.DefaultWorkspaceID}
|
||||
ctx := authz.WithPrincipal(context.Background(), principal)
|
||||
instanceRepo := persistencemock.NewInstanceRepositoryMock()
|
||||
|
||||
instance := &entity.Instance{
|
||||
ID: "inst-1",
|
||||
ClusterID: "cluster-1",
|
||||
Name: "demo",
|
||||
Namespace: "default",
|
||||
ID: "inst-1",
|
||||
WorkspaceID: entity.DefaultWorkspaceID,
|
||||
OwnerID: "user-1",
|
||||
ClusterID: "cluster-1",
|
||||
Name: "demo",
|
||||
Namespace: "default",
|
||||
}
|
||||
if err := instanceRepo.Create(ctx, instance); err != nil {
|
||||
t.Fatalf("failed to seed instance: %v", err)
|
||||
@ -40,8 +45,63 @@ func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
||||
t.Fatalf("DeleteInstance returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := instanceRepo.GetByID(ctx, instance.ID); !errors.Is(err, entity.ErrInstanceNotFound) {
|
||||
t.Fatalf("expected instance removed, got err=%v", err)
|
||||
waitForInstanceDeleted(t, ctx, instanceRepo, instance.ID)
|
||||
}
|
||||
|
||||
func TestEnforceNamespaceValuesOverridesChartNamespaceKnobs(t *testing.T) {
|
||||
instance := &entity.Instance{
|
||||
Namespace: "ocdp-u-alice",
|
||||
Values: map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"namespaceOverride": "default",
|
||||
"targetNamespace": "default",
|
||||
"global": map[string]interface{}{
|
||||
"namespace": "default",
|
||||
"namespaceOverride": "default",
|
||||
},
|
||||
"image": map[string]interface{}{
|
||||
"repository": "nginx",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
enforceNamespaceValues(instance)
|
||||
|
||||
if instance.Values["namespace"] != "ocdp-u-alice" {
|
||||
t.Fatalf("expected top-level namespace to be enforced, got %#v", instance.Values["namespace"])
|
||||
}
|
||||
if instance.Values["namespaceOverride"] != "ocdp-u-alice" {
|
||||
t.Fatalf("expected namespaceOverride to be enforced, got %#v", instance.Values["namespaceOverride"])
|
||||
}
|
||||
if instance.Values["targetNamespace"] != "ocdp-u-alice" {
|
||||
t.Fatalf("expected targetNamespace to be enforced, got %#v", instance.Values["targetNamespace"])
|
||||
}
|
||||
global, ok := instance.Values["global"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected global map, got %#v", instance.Values["global"])
|
||||
}
|
||||
if global["namespace"] != "ocdp-u-alice" || global["namespaceOverride"] != "ocdp-u-alice" {
|
||||
t.Fatalf("expected global namespace keys to be enforced, got %#v", global)
|
||||
}
|
||||
}
|
||||
|
||||
func waitForInstanceDeleted(t *testing.T, ctx context.Context, repo repository.InstanceRepository, id string) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.After(2 * time.Second)
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-deadline:
|
||||
_, err := repo.GetByID(ctx, id)
|
||||
t.Fatalf("expected instance removed, got err=%v", err)
|
||||
case <-ticker.C:
|
||||
if _, err := repo.GetByID(ctx, id); errors.Is(err, entity.ErrInstanceNotFound) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,11 +6,12 @@ import (
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/authz"
|
||||
)
|
||||
|
||||
// MonitoringService 监控服务
|
||||
type MonitoringService struct {
|
||||
clusterRepo repository.ClusterRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
metricsClient repository.MetricsClient
|
||||
}
|
||||
|
||||
@ -20,13 +21,24 @@ func NewMonitoringService(
|
||||
metricsClient repository.MetricsClient,
|
||||
) *MonitoringService {
|
||||
return &MonitoringService{
|
||||
clusterRepo: clusterRepo,
|
||||
clusterRepo: clusterRepo,
|
||||
metricsClient: metricsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterMonitoring 获取单个集群的监控信息
|
||||
func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster metrics: %w", err)
|
||||
@ -36,6 +48,10 @@ func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID
|
||||
|
||||
// ListClusterMonitoring 获取所有集群的监控信息
|
||||
func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entity.ClusterMetrics, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
// 获取所有集群
|
||||
clusters, err := s.clusterRepo.List(ctx)
|
||||
if err != nil {
|
||||
@ -45,6 +61,9 @@ func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entit
|
||||
// 获取每个集群的监控数据
|
||||
result := make([]*entity.ClusterMetrics, 0, len(clusters))
|
||||
for _, cluster := range clusters {
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
continue
|
||||
}
|
||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, cluster.ID)
|
||||
if err != nil {
|
||||
// 如果某个集群获取失败,记录错误但继续
|
||||
@ -93,10 +112,20 @@ func (s *MonitoringService) GetMonitoringSummary(ctx context.Context) (*entity.M
|
||||
|
||||
// GetNodeMetrics 获取集群的节点指标
|
||||
func (s *MonitoringService) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if !authz.CanReadResource(principal, cluster.WorkspaceID, cluster.OwnerID, cluster.Visibility) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
nodes, err := s.metricsClient.GetNodeMetrics(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get node metrics: %w", err)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
|
||||
54
backend/internal/domain/service/quota_quantity.go
Normal file
54
backend/internal/domain/service/quota_quantity.go
Normal file
@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
func normalizeStandardQuotaQuantity(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
upper := strings.ToUpper(value)
|
||||
switch {
|
||||
case strings.HasSuffix(upper, "MB"):
|
||||
return strings.TrimSpace(value[:len(value)-2]) + "M"
|
||||
case strings.HasSuffix(upper, "GB"):
|
||||
return strings.TrimSpace(value[:len(value)-2]) + "G"
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGPUMemoryQuota(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
upper := strings.ToUpper(value)
|
||||
multiplier := int64(1)
|
||||
number := value
|
||||
switch {
|
||||
case strings.HasSuffix(upper, "MB"):
|
||||
number = strings.TrimSpace(value[:len(value)-2])
|
||||
case strings.HasSuffix(upper, "M"):
|
||||
number = strings.TrimSpace(value[:len(value)-1])
|
||||
case strings.HasSuffix(upper, "GB"):
|
||||
number = strings.TrimSpace(value[:len(value)-2])
|
||||
multiplier = 1000
|
||||
case strings.HasSuffix(upper, "G"):
|
||||
number = strings.TrimSpace(value[:len(value)-1])
|
||||
multiplier = 1000
|
||||
case strings.HasSuffix(upper, "GIB"):
|
||||
number = strings.TrimSpace(value[:len(value)-3])
|
||||
multiplier = 1024
|
||||
case strings.HasSuffix(upper, "GI"):
|
||||
number = strings.TrimSpace(value[:len(value)-2])
|
||||
multiplier = 1024
|
||||
}
|
||||
parsed, err := strconv.ParseInt(number, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return "", entity.ErrInvalidTenantResourceQuota
|
||||
}
|
||||
return strconv.FormatInt(parsed*multiplier, 10), nil
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// RegistryService Registry 管理领域服务
|
||||
@ -26,8 +27,21 @@ func NewRegistryService(
|
||||
|
||||
// CreateRegistry 创建新 Registry
|
||||
func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 生成 ID
|
||||
registry.ID = uuid.New().String()
|
||||
registry.OwnerID = principal.UserID
|
||||
registry.WorkspaceID = principal.WorkspaceID
|
||||
if principal.IsAdmin() && registry.WorkspaceID == "" {
|
||||
registry.WorkspaceID = entity.DefaultWorkspaceID
|
||||
}
|
||||
if !principal.IsAdmin() && registry.Visibility == authz.VisibilityGlobalShared {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
registry.Visibility = authz.NormalizeVisibility(principal.Role, registry.Visibility)
|
||||
|
||||
// 验证
|
||||
if err := registry.Validate(); err != nil {
|
||||
@ -35,9 +49,11 @@ func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.R
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
existingRegistry, _ := s.registryRepo.GetByName(ctx, registry.Name)
|
||||
if existingRegistry != nil {
|
||||
return entity.ErrRegistryExists
|
||||
registries, _ := s.registryRepo.List(ctx)
|
||||
for _, existingRegistry := range registries {
|
||||
if existingRegistry.Name == registry.Name && existingRegistry.WorkspaceID == registry.WorkspaceID && existingRegistry.OwnerID == registry.OwnerID {
|
||||
return entity.ErrRegistryExists
|
||||
}
|
||||
}
|
||||
|
||||
return s.registryRepo.Create(ctx, registry)
|
||||
@ -45,16 +61,41 @@ func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.R
|
||||
|
||||
// GetRegistry 获取 Registry
|
||||
func (s *RegistryService) GetRegistry(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
return s.registryRepo.GetByID(ctx, id)
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// UpdateRegistry 更新 Registry
|
||||
func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查是否存在
|
||||
_, err := s.registryRepo.GetByID(ctx, registry.ID)
|
||||
existing, err := s.registryRepo.GetByID(ctx, registry.ID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
if !authz.CanWriteResource(principal, existing.WorkspaceID, existing.OwnerID, existing.Visibility) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
registry.WorkspaceID = existing.WorkspaceID
|
||||
registry.OwnerID = existing.OwnerID
|
||||
if principal.IsAdmin() {
|
||||
registry.Visibility = authz.NormalizeVisibility(principal.Role, registry.Visibility)
|
||||
} else {
|
||||
registry.Visibility = existing.Visibility
|
||||
}
|
||||
|
||||
// 验证
|
||||
if err := registry.Validate(); err != nil {
|
||||
@ -66,27 +107,47 @@ func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.R
|
||||
|
||||
// DeleteRegistry 删除 Registry
|
||||
func (s *RegistryService) DeleteRegistry(ctx context.Context, id string) error {
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return entity.ErrUnauthorized
|
||||
}
|
||||
// 检查是否存在
|
||||
_, err := s.registryRepo.GetByID(ctx, id)
|
||||
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
if !authz.CanWriteResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||
return entity.ErrForbidden
|
||||
}
|
||||
|
||||
return s.registryRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ListRegistries 列出所有 Registries
|
||||
func (s *RegistryService) ListRegistries(ctx context.Context) ([]*entity.Registry, error) {
|
||||
return s.registryRepo.List(ctx)
|
||||
principal, err := authz.RequirePrincipal(ctx)
|
||||
if err != nil {
|
||||
return nil, entity.ErrUnauthorized
|
||||
}
|
||||
registries, err := s.registryRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
visible := make([]*entity.Registry, 0, len(registries))
|
||||
for _, registry := range registries {
|
||||
if authz.CanReadResource(principal, registry.WorkspaceID, registry.OwnerID, registry.Visibility) {
|
||||
visible = append(visible, registry)
|
||||
}
|
||||
}
|
||||
return visible, nil
|
||||
}
|
||||
|
||||
// CheckHealth 检查 Registry 健康状态
|
||||
func (s *RegistryService) CheckHealth(ctx context.Context, id string) error {
|
||||
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||
registry, err := s.GetRegistry(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.CheckHealth(ctx, registry)
|
||||
}
|
||||
|
||||
|
||||
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