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
|
||||
}
|
||||
Reference in New Issue
Block a user