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
}