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:
Ivan087
2026-05-12 16:15:14 +08:00
parent c5e51ed069
commit 7f238a3168
172 changed files with 15703 additions and 3162 deletions

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

@ -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"`
}

View File

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

View 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)
}

View 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)
}
}

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View 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)
}

View File

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

View File

@ -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 修改密码(会触发全局登出)

View File

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

View File

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

View File

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

View File

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

View 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
}

View File

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

View 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(),
})
}