feat: complete E2E deployment flow with storage layered config and values template versioning

- Instance deployment: charts browser, deploy modal, instances list
- Values Template version management (create/history/rollback)
- Storage layered config (cluster > workspace > shared priority)
- Cluster credential decryptIfNeeded for mixed encrypted/plaintext kubeconfig
- YAML syntax validation (client-side + server-side warning)
- Frontend: charts, instances, storage, templates, admin pages
- Backend: storage service, instance service, cluster service, helm client
- Multi-Tenant Kubeconfig.md: added by user
This commit is contained in:
Ivan087
2026-04-30 16:31:00 +08:00
parent 985369d40f
commit 47849042a7
42 changed files with 2029 additions and 255 deletions

View File

@ -76,6 +76,7 @@ func WorkspaceDTOFromEntity(workspace *entity.Workspace) *WorkspaceDTO {
return &WorkspaceDTO{
ID: workspace.ID,
Name: workspace.Name,
ClusterIDs: workspace.ClusterIDs,
Description: workspace.Description,
CreatedBy: workspace.CreatedBy,
CreatedAt: workspace.CreatedAt,

View File

@ -7,6 +7,7 @@ type CreateStorageRequest struct {
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
// NFS 配置
NFS NFSConfigDTO `json:"nfs,omitempty"`
@ -23,6 +24,7 @@ type UpdateStorageRequest struct {
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
// NFS 配置
NFS NFSConfigDTO `json:"nfs,omitempty"`
@ -52,17 +54,18 @@ type HostPathConfigDTO struct {
// StorageResponse 存储后端响应
type StorageResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Config StorageConfigDTO `json:"config"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id,omitempty"`
ClusterID string `json:"cluster_id,omitempty"`
OwnerID string `json:"owner_id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Config StorageConfigDTO `json:"config"`
Description string `json:"description"`
IsDefault bool `json:"is_default"`
IsShared bool `json:"is_shared"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// StorageConfigDTO 存储配置(脱敏后)

View File

@ -4,24 +4,32 @@ import "time"
// WorkspaceDTO 工作空间 DTO
type WorkspaceDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
Name string `json:"name"`
ClusterIDs []string `json:"cluster_ids,omitempty"`
Quotas []*QuotaDTO `json:"quotas,omitempty"`
Description string `json:"description,omitempty"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateWorkspaceRequest 创建工作空间请求
// CreateWorkspaceRequest 创建工作空间请求(包含配额设置)
type CreateWorkspaceRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
ClusterIDs []string `json:"cluster_ids"`
// Quotas can be set during creation
CPU *QuotaValue `json:"cpu"`
GPU *QuotaValue `json:"gpu"`
GPUMemory *QuotaValue `json:"gpu_memory"`
}
// UpdateWorkspaceRequest 更新工作空间请求
type UpdateWorkspaceRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Name string `json:"name"`
Description string `json:"description"`
ClusterIDs []string `json:"cluster_ids"`
}
// QuotaDTO 配额 DTO

View File

@ -83,14 +83,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
// 获取用户信息
// TODO: 从 token 解析用户信息或从服务获取
// 返回响应
// 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致
response := &dto.AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
Username: req.Username,
}
respondJSON(w, http.StatusOK, response)
respondSuccess(w, "Login successful", response)
}
// RefreshToken 刷新 Token
@ -117,11 +117,11 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
return
}
// 返回响应
// 返回响应 - 使用 respondSuccess 包装
response := &dto.AuthResponse{
AccessToken: newAccessToken,
RefreshToken: req.RefreshToken,
}
respondJSON(w, http.StatusOK, response)
respondSuccess(w, "Token refreshed", response)
}

View File

@ -188,6 +188,7 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
LastOperation: string(instance.LastOperation),
LastError: instance.LastError,
Revision: instance.Revision,
Values: instance.Values,
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})

View File

@ -10,6 +10,14 @@ import (
"github.com/ocdp/cluster-service/internal/domain/service"
)
// StorageResolutionResponse 分层存储解析响应
type StorageResolutionResponse struct {
Storage *dto.StorageResponse `json:"storage,omitempty"`
ValuesYAML string `json:"values_yaml,omitempty"`
Source string `json:"source,omitempty"` // workspace, cluster, shared
Message string `json:"message,omitempty"`
}
// StorageHandler Storage Backend Handler
type StorageHandler struct {
storageService *service.StorageService
@ -77,6 +85,7 @@ func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) {
req.Description,
req.IsDefault,
req.IsShared,
req.ClusterID,
)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error())
@ -252,6 +261,45 @@ func (h *StorageHandler) DeleteStorage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ResolveStorage 预览分层存储解析结果
// @Summary 预览分层存储解析结果
// @Description 根据 cluster_id 和 workspace_id 解析出最终生效的存储配置
// @Tags Storage
// @Produce json
// @Security BearerAuth
// @Param cluster_id query string false "Cluster ID"
// @Param workspace_id query string false "Workspace ID"
// @Success 200 {object} StorageResolutionResponse
// @Router /storage-backends/resolve [get]
func (h *StorageHandler) ResolveStorage(w http.ResponseWriter, r *http.Request) {
clusterID := r.URL.Query().Get("cluster_id")
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
workspaceID = r.Header.Get("X-Workspace-ID")
}
resolution, err := h.storageService.ResolveStorageConfig(r.Context(), clusterID, workspaceID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to resolve storage config", err.Error())
return
}
if resolution == nil || resolution.Storage == nil {
respondJSON(w, http.StatusOK, &StorageResolutionResponse{
Message: "No default storage configured",
})
return
}
response := &StorageResolutionResponse{
Storage: toStorageResponse(resolution.Storage),
ValuesYAML: resolution.ValuesYAML,
Source: resolution.Source,
}
respondJSON(w, http.StatusOK, response)
}
// toStorageResponse 转换为响应 DTO
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
config := dto.StorageConfigDTO{}
@ -278,6 +326,7 @@ func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
return &dto.StorageResponse{
ID: storage.ID,
WorkspaceID: storage.WorkspaceID,
ClusterID: storage.ClusterID,
OwnerID: storage.OwnerID,
Name: storage.Name,
Type: string(storage.Type),

View File

@ -26,7 +26,7 @@ func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService
// CreateWorkspace 创建工作空间
// @Summary 创建工作空间
// @Description 创建新的工作空间Admin 专用)
// @Description 创建新的工作空间Admin 专用,支持 cluster_ids 和初始配额
// @Tags workspace
// @Accept json
// @Produce json
@ -48,7 +48,31 @@ func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Reques
// 获取创建者 ID
userID := GetUserIDFromRequest(r)
workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID)
// 准备配额
quotas := make(map[entity.ResourceType]struct {
HardLimit float64
SoftLimit float64
})
if req.CPU != nil {
quotas[entity.ResourceCPU] = struct {
HardLimit float64
SoftLimit float64
}{req.CPU.HardLimit, req.CPU.SoftLimit}
}
if req.GPU != nil {
quotas[entity.ResourceGPU] = struct {
HardLimit float64
SoftLimit float64
}{req.GPU.HardLimit, req.GPU.SoftLimit}
}
if req.GPUMemory != nil {
quotas[entity.ResourceGPUMemory] = struct {
HardLimit float64
SoftLimit float64
}{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit}
}
workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID, req.ClusterIDs, quotas)
if err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")
return
@ -129,6 +153,9 @@ func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Reques
if req.Description != "" {
workspace.Description = req.Description
}
if req.ClusterIDs != nil {
workspace.ClusterIDs = req.ClusterIDs
}
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
respondError(w, http.StatusBadRequest, err.Error(), "")

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"time"
@ -113,22 +114,24 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
install.Namespace = instance.Namespace
install.CreateNamespace = true
install.Wait = true
install.Timeout = 5 * time.Minute
install.Timeout = 1 * time.Minute
// 加载 Chart从本地路径或 OCI registry
// 这里简化处理,假设 chart 已经被拉取到本地
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
chart, err := loader.Load(chartPath)
if err != nil {
return fmt.Errorf("failed to load chart: %w", err)
}
// 执行安装
log.Printf("[helm-install] step=run instance=%s values=%v", instance.Name, instance.Values)
t0 := time.Now()
rel, err := install.Run(chart, instance.Values)
log.Printf("[helm-install] step=runDone instance=%s elapsed=%v err=%v", instance.Name, time.Since(t0), err)
if err != nil {
return fmt.Errorf("failed to install release: %w", err)
}
log.Printf("[helm-install] step=done instance=%s revision=%d", instance.Name, rel.Version)
// 更新 revision状态由调用方根据操作结果设置
instance.Revision = rel.Version

View File

@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
@ -74,23 +75,147 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
}
// ListRepositories 列出 Registry 中的所有 repositories
// 优先使用 OCI _catalog API失败时回退到 Harbor REST API v2
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
reg, err := c.getRegistry(registry)
if err != nil {
return nil, err
}
repositories := make([]string, 0)
err = reg.Repositories(ctx, "", func(repos []string) error {
repositories = append(repositories, repos...)
return nil
})
// 尝试 OCI _catalog API
reg, err := c.getRegistry(registry)
log.Printf("[DEBUG ListRepositories] registry=%s, getRegistry err=%v", registry.URL, err)
if err == nil {
err = reg.Repositories(ctx, "", func(repos []string) error {
log.Printf("[DEBUG ListRepositories] OCI got repos batch: %d", len(repos))
repositories = append(repositories, repos...)
return nil
})
log.Printf("[DEBUG ListRepositories] OCI reg.Repositories returned: err=%v, total_repos=%d", err, len(repositories))
}
log.Printf("[DEBUG ListRepositories] post-OCI check: err=%v, repos_count=%d", err, len(repositories))
if err == nil && len(repositories) > 0 {
log.Printf("[DEBUG ListRepositories] OCI success, returning %d repos", len(repositories))
return repositories, nil
}
// 回退: 使用 Harbor REST API v2
log.Printf("[Harbor Fallback] OCI failed (err=%v, repos=%d), checking if Harbor...", err, len(repositories))
log.Printf("[Harbor Fallback] registry.URL=%s, contains 'harbor'=%v", registry.URL, strings.Contains(registry.URL, "harbor"))
if strings.Contains(registry.URL, "harbor") {
log.Printf("[Harbor Fallback] Yes, this is Harbor! Calling Harbor REST API...")
repos, fallbackErr := c.listHarborRepositories(registry)
log.Printf("[Harbor Fallback] Got %d repos, err=%v", len(repos), fallbackErr)
if fallbackErr == nil && len(repos) > 0 {
log.Printf("[Harbor Fallback] Returning %d repos from Harbor API", len(repos))
return repos, nil
}
if err != nil {
return nil, fmt.Errorf("failed to list repositories: %w", err)
}
return nil, fallbackErr
}
if err != nil {
return nil, fmt.Errorf("failed to list repositories: %w", err)
}
return repositories, nil
}
// listHarborRepositories 使用 Harbor REST API v2 获取仓库列表
func (c *OCIClient) listHarborRepositories(registry *entity.Registry) ([]string, error) {
// 解析 Harbor URL 基础地址
baseURL := registry.URL
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = strings.TrimPrefix(baseURL, "https://")
baseURL = strings.TrimPrefix(baseURL, "http://")
harborHost := "https://" + baseURL
// 获取认证信息
username := registry.Username
password := registry.Password
if username == "" || password == "" {
username = os.Getenv("HARBOR_USERNAME")
password = os.Getenv("HARBOR_PASSWORD")
}
// 获取项目列表
projectsURL := harborHost + "/api/v2.0/projects"
req, err := http.NewRequest("GET", projectsURL, nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(username, password)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to list projects: status %d", resp.StatusCode)
}
var projects []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil {
return nil, err
}
repositories := make([]string, 0)
pageSize := 100
for _, project := range projects {
page := 1
log.Printf("[listHarborRepositories] Processing project: %s", project.Name)
for {
reposURL := fmt.Sprintf("%s/api/v2.0/projects/%s/repositories?page=%d&page_size=%d",
harborHost, project.Name, page, pageSize)
req, err := http.NewRequest("GET", reposURL, nil)
if err != nil {
log.Printf("[listHarborRepositories] page %d: NewRequest error: %v", page, err)
break
}
req.SetBasicAuth(username, password)
resp, err := c.httpClient.Do(req)
if err != nil {
log.Printf("[listHarborRepositories] page %d: Do error: %v", page, err)
break
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 200))
resp.Body.Close()
log.Printf("[listHarborRepositories] page %d: HTTP %d, body: %s", page, resp.StatusCode, string(bodyBytes))
break
}
var repos []struct {
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
resp.Body.Close()
log.Printf("[listHarborRepositories] page %d: Decode error: %v", page, err)
break
}
resp.Body.Close()
log.Printf("[listHarborRepositories] page %d: got %d repos", page, len(repos))
if len(repos) == 0 {
break
}
for _, repo := range repos {
repositories = append(repositories, repo.Name)
}
page++
}
}
log.Printf("[listHarborRepositories] Total repos collected: %d", len(repositories))
return repositories, nil
}

View File

@ -83,7 +83,28 @@ func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID stri
return s, nil
}
}
return nil, entity.ErrStorageNotFound
return nil, nil
}
// GetByCluster 获取 cluster 关联的存储后端
func (r *StorageRepositoryMock) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
if s.ClusterID == clusterID {
result = append(result, s)
}
}
return result, nil
}
// GetDefaultByCluster 获取 cluster 的默认存储后端
func (r *StorageRepositoryMock) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) {
for _, s := range r.storages {
if s.ClusterID == clusterID && s.IsDefault {
return s, nil
}
}
return nil, nil
}
// List 列出所有存储(管理员用)

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/google/uuid"
@ -124,11 +125,11 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
return nil, fmt.Errorf("failed to get cluster: %w", err)
}
// 解密敏感数据
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
return cluster, nil
}
@ -169,15 +170,35 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
return nil, fmt.Errorf("failed to get cluster: %w", err)
}
// 解密敏感数据
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
return cluster, nil
}
// decryptIfNeeded 解密数据。如果数据以 "apiVersion:" 或 "kind:" 开头kubeconfig 格式),
// 则跳过解密直接返回原值。
func (r *ClusterRepository) decryptIfNeeded(data string, fieldName string) string {
if data == "" {
return ""
}
// 检测 kubeconfig 格式(明文 YAML
if (len(data) > 10 && data[:11] == "apiVersion:") ||
(len(data) > 5 && data[:5] == "kind:") {
return data
}
// 否则尝试解密
decrypted, err := r.encryptor.Decrypt(data)
if err != nil {
log.Printf("[ClusterRepository] WARNING: failed to decrypt %s for field %s: %v (field will be empty)", data[:min(50, len(data))], fieldName, err)
return ""
}
return decrypted
}
// Update 更新集群
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
cluster.UpdatedAt = time.Now()
@ -352,18 +373,18 @@ func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, err
cluster.OwnerID = ownerID.String
cluster.DefaultNamespace = defaultNamespace.String
// 解密敏感数据
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
if encryptedCAData.Valid {
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData.String)
cluster.CAData = r.decryptIfNeeded(encryptedCAData.String, "ca_data")
}
if encryptedCertData.Valid {
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData.String)
cluster.CertData = r.decryptIfNeeded(encryptedCertData.String, "cert_data")
}
if encryptedKeyData.Valid {
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData.String)
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData.String, "key_data")
}
if encryptedToken.Valid {
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken.String)
cluster.Token = r.decryptIfNeeded(encryptedToken.String, "token")
}
clusters = append(clusters, cluster)

View File

@ -12,6 +12,14 @@ import (
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// sqlNullString converts empty string to sql.NullString for proper NULL handling
func sqlNullString(s string) interface{} {
if s == "" {
return sql.NullString{Valid: false}
}
return sql.NullString{String: s, Valid: true}
}
// StorageRepository PostgreSQL 存储后端仓储实现
type StorageRepository struct {
db *DB
@ -34,14 +42,15 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB
}
query := `
INSERT INTO storage_backends (id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
INSERT INTO storage_backends (id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`
_, err = r.db.conn.ExecContext(ctx, query,
storage.ID,
storage.WorkspaceID,
storage.OwnerID,
sqlNullString(storage.ClusterID),
sqlNullString(storage.OwnerID),
storage.Name,
storage.Type,
configJSON,
@ -62,17 +71,19 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB
// GetByID 根据 ID 获取存储后端
func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE id = $1
`
storage := &entity.StorageBackend{}
var configJSON []byte
var wsID, clusterID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&wsID,
&clusterID,
&ownerID,
&storage.Name,
&storage.Type,
&configJSON,
@ -82,6 +93,9 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto
&storage.CreatedAt,
&storage.UpdatedAt,
)
storage.WorkspaceID = wsID.String
storage.ClusterID = clusterID.String
storage.OwnerID = ownerID.String
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
@ -100,17 +114,19 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto
// GetByName 根据名称获取存储后端
func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 AND name = $2
`
storage := &entity.StorageBackend{}
var configJSON []byte
var wsID, clusterID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&wsID,
&clusterID,
&ownerID,
&storage.Name,
&storage.Type,
&configJSON,
@ -120,6 +136,9 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str
&storage.CreatedAt,
&storage.UpdatedAt,
)
storage.WorkspaceID = wsID.String
storage.ClusterID = clusterID.String
storage.OwnerID = ownerID.String
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
@ -138,7 +157,7 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str
// GetByWorkspace 获取 workspace 的所有存储后端
func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 OR is_shared = TRUE
ORDER BY is_default DESC, name
@ -156,7 +175,7 @@ func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID stri
// GetShared 获取所有共享存储后端
func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE is_shared = TRUE
ORDER BY name
@ -174,7 +193,7 @@ func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBac
// GetDefault 获取 workspace 的默认存储后端
func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE workspace_id = $1 AND is_default = TRUE
LIMIT 1
@ -182,10 +201,12 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
storage := &entity.StorageBackend{}
var configJSON []byte
var wsID, clusterID, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&wsID,
&clusterID,
&ownerID,
&storage.Name,
&storage.Type,
&configJSON,
@ -195,6 +216,9 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
&storage.CreatedAt,
&storage.UpdatedAt,
)
storage.WorkspaceID = wsID.String
storage.ClusterID = clusterID.String
storage.OwnerID = ownerID.String
if err == sql.ErrNoRows {
return nil, nil
@ -210,6 +234,68 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
return storage, nil
}
// GetByCluster 获取 cluster 关联的存储后端列表
func (r *StorageRepository) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE cluster_id = $1
ORDER BY is_default DESC, name
`
rows, err := r.db.conn.QueryContext(ctx, query, clusterID)
if err != nil {
return nil, fmt.Errorf("failed to list storage by cluster: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// GetDefaultByCluster 获取 cluster 的默认存储后端
func (r *StorageRepository) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
WHERE cluster_id = $1 AND is_default = TRUE
LIMIT 1
`
storage := &entity.StorageBackend{}
var configJSON []byte
var wsID, clusterIDNull, ownerID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, clusterID).Scan(
&storage.ID,
&wsID,
&clusterIDNull,
&ownerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
storage.WorkspaceID = wsID.String
storage.ClusterID = clusterIDNull.String
storage.OwnerID = ownerID.String
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get default storage by cluster: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// Update 更新存储后端
func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error {
storage.UpdatedAt = time.Now()
@ -221,8 +307,8 @@ func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageB
query := `
UPDATE storage_backends
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, updated_at = $7
WHERE id = $8
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, cluster_id = $7, updated_at = $8
WHERE id = $9
`
result, err := r.db.conn.ExecContext(ctx, query,
@ -232,6 +318,7 @@ func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageB
storage.Description,
storage.IsDefault,
storage.IsShared,
sqlNullString(storage.ClusterID),
storage.UpdatedAt,
storage.ID,
)
@ -276,7 +363,7 @@ func (r *StorageRepository) Delete(ctx context.Context, id string) error {
// List 列出所有存储后端(管理员用)
func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) {
query := `
SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at
FROM storage_backends
ORDER BY workspace_id, name
`
@ -296,9 +383,11 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke
for rows.Next() {
storage := &entity.StorageBackend{}
var configJSON []byte
var wsID, clusterID sql.NullString
err := rows.Scan(
&storage.ID,
&storage.WorkspaceID,
&wsID,
&clusterID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
@ -312,6 +401,8 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke
if err != nil {
return nil, fmt.Errorf("failed to scan storage: %w", err)
}
storage.WorkspaceID = wsID.String
storage.ClusterID = clusterID.String
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}

View File

@ -5,14 +5,15 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)
// BootstrapConfig 预注入配置
type BootstrapConfig struct {
Enabled bool `json:"enabled"`
Users []UserSeed `json:"users"`
Registries []RegistrySeed `json:"registries"`
Clusters []ClusterSeed `json:"clusters"`
Enabled bool `json:"enabled"`
Users []UserSeed `json:"users"`
Registries []RegistrySeed `json:"registries"`
Clusters []ClusterSeed `json:"clusters"`
}
// UserSeed 用户预注入数据
@ -45,11 +46,11 @@ type ClusterSeed struct {
// LoadBootstrapConfig 加载预注入配置
// 支持从文件或环境变量加载
//
//
// 加载优先级:
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
// 2. Mock 模式: 配置文件 config/bootstrap.json
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据
// 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取
func LoadBootstrapConfig() (*BootstrapConfig, error) {
// 1. 优先从环境变量加载
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
@ -62,7 +63,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
// 2. 检查适配器模式
adapterMode := os.Getenv("ADAPTER_MODE")
// Mock 模式: 使用配置文件(假数据)
if adapterMode == "mock" {
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
@ -89,49 +90,87 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
return &config, nil
}
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据
// 3. 真实模式 (mode 1, mode 2): 从 .env 读取
return GetDefaultBootstrapConfig(), nil
}
// GetDefaultBootstrapConfig 获取默认的预注入配置(示例)
// GetDefaultBootstrapConfig 从 .env 加载 bootstrap 数据。
// 支持 BOOTSTRAP_CLUSTERS (逗号分隔的集群名) 以及每个集群的
// BOOTSTRAP_CLUSTER_<NAME>_HOST, _CA, _CERT, _KEY, _DESC。
// 支持 BOOTSTRAP_REGISTRY_* 环境变量。
// 支持 BOOTSTRAP_ADMIN_USER/PASS/EMAIL。
func GetDefaultBootstrapConfig() *BootstrapConfig {
// Load clusters from .env (comma-separated list of cluster names)
clusterStr := os.Getenv("BOOTSTRAP_CLUSTERS")
var clusterSeeds []ClusterSeed
if clusterStr != "" {
clusterNames := strings.Split(clusterStr, ",")
for _, name := range clusterNames {
name = strings.TrimSpace(name)
if name == "" {
continue
}
key := sanitizeEnvKey(name)
host := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_HOST")
ca := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CA")
cert := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CERT")
keyData := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_KEY")
desc := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_DESC")
if host != "" {
clusterSeeds = append(clusterSeeds, ClusterSeed{
Name: name,
Host: host,
Description: desc,
CAData: ca,
CertData: cert,
KeyData: keyData,
})
}
}
}
// Load registry from .env
var registrySeeds []RegistrySeed
regName := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_NAME"))
regURL := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_URL"))
if regName != "" && regURL != "" {
registrySeeds = append(registrySeeds, RegistrySeed{
Name: regName,
URL: regURL,
Description: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_DESC")),
Username: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_USER")),
Password: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_PASS")),
Insecure: strings.ToLower(strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_INSECURE"))) == "true",
})
}
// Load users from .env
var userSeeds []UserSeed
adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER"))
adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS"))
if adminUser != "" {
userSeeds = append(userSeeds, UserSeed{
Username: adminUser,
Password: adminPass,
Email: strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_EMAIL")),
})
}
return &BootstrapConfig{
Enabled: true,
Users: []UserSeed{
{
Username: "admin",
Password: "admin123",
Email: "admin@example.com",
},
},
Registries: []RegistrySeed{
{
Name: "harbor-bwgdi",
URL: "https://harbor.bwgdi.com",
Description: "BWGDI Harbor Registry",
Username: "admin",
Password: "BWGDIP@ssw0rd1401#",
Insecure: false,
},
},
Clusters: []ClusterSeed{
{
Name: "cluster1",
Host: "https://10.6.14.123:6443",
Description: "K3s Cluster 1",
CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTlRVME9ETTJOemt3SGhjTk1qVXdPREU0TURJeU1URTVXaGNOTXpVd09ERTJNREl5TVRFNQpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTlRVME9ETTJOemt3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFTaVBJUW5LZXR2VjQ3cHUyLytMV1lZaGJjbUY3V3RZQnArOGxDaUVKdkcKaFAyaE5BWVVmZDUrRnN5VVN3bDBTV3NoT3BORmRMc0NzY3pISkhycUpWYUVvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVTlCa3lhSGpPVG1RM29LYWlOaXFmCjVwZTF4L293Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnTzR4M3EyNmhhL1Z0NTRCT1Awc1hVNGt5ckVpNDR6TUcKc0d0Z25LY0NLbk1DSVFEcVhsSzBqSGNKSVE2bTRWanRub0VQWGdzQ2JrdW45WmxvVmxhbWtPNXAzZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrakNDQVRlZ0F3SUJBZ0lJVjVQT1FRblJoSGd3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOelUxTkRnek5qYzVNQjRYRFRJMU1EZ3hPREF5TWpFeE9Wb1hEVEkyTURneApPREF5TWpFeE9Wb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJMTjcrbjNXRDY0TThTMEEKT1Bpd2hReFZRNWdLTStRTk11REFzSlM1UVZFdTIyajZwaFlQYTNyQWFLU1hnZE1EdVYvbTRUamxTQmxCM2dJQwpnZW5wdTc2alNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCVGlxTWRFM0xYbElwVHRiREJnN0ZVcmV1NHVVREFLQmdncWhrak9QUVFEQWdOSkFEQkcKQWlFQXRPQ0s4ZmdzZmxhaTczcXdXMkhQbWM2bDVXNmR2L1BzNGhHNDZFRkV0VlFDSVFDenFkQitkZnFiWkJ5cwpNUm0zbDU1N3pNOFBNcDhRUE5lVFdiM0VoOEdtVGc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlCZGpDQ0FSMmdBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwClpXNTBMV05oUURFM05UVTBPRE0yTnprd0hoY05NalV3T0RFNE1ESXlNVEU1V2hjTk16VXdPREUyTURJeU1URTUKV2pBak1TRXdId1lEVlFRRERCaHJNM010WTJ4cFpXNTBMV05oUURFM05UVTBPRE0yTnprd1dUQVRCZ2NxaGtqTwpQUUlCQmdncWhrak9QUU1CQndOQ0FBU3JxQzd2RUhKYzQzUThIWG5MT0VQeXkyM0tYZzlHOVkycTJUaVFLMGhoCkJvNnh1WUxDMTFSWkhGNC85NGZJZitZa3BCcmRpcFFNTjRSaVVrUGZzM28zbzBJd1FEQU9CZ05WSFE4QkFmOEUKQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVU0cWpIUk55MTVTS1U3V3d3WU94VgpLM3J1TGxBd0NnWUlLb1pJemowRUF3SURSd0F3UkFJZ041WmJQaEs4YkwxWllmcStGTVNNbkFCdEgzRSsxcnFoClpRUHY4UWM3S09nQ0lCMWhBclM5SXhKU1dYYlV3ZWE4WU0yVUNEMlplYTVxMHJMQnd4SHFqb3RjCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
KeyData: "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUpuM2dPd0lBNzJGMXE2dkhvMHdDRk1RS0VXVmVnejlQYy9NRFhVVDU5c3pvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFczN2NmZkWVByZ3p4TFFBNCtMQ0ZERlZEbUFvejVBMHk0TUN3bExsQlVTN2JhUHFtRmc5cgplc0JvcEplQjB3TzVYK2JoT09WSUdVSGVBZ0tCNmVtN3ZnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=",
},
{
Name: "cluster2",
Host: "https://10.6.80.12:6443",
Description: "Kubernetes Cluster 2",
CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWCtGQVJITzJWdVl3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHpOVEV3TWpnd016VXhOVE5hTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUROdFJSeG5JYVU2MS93UHVWNkpiR0hLaWtaZWVmYXlNOEFzVHRQeXQwaU5BaFgvVWNUT1pSVWYyZmUKTXBKSFNDdy9QQjJ2d1dCZDB2OVBEVWZ6RTYxL0lKcmhWZU54NmRxK0VPdVFqRmI2TlMvbkpiWmpXVFoyRFhBRQpkS1lwaGpXWGV3dWVuK0htTjlyK2tIZGlORVdmc0xDb1hWOFFMSmVRZXF4NHY2eTFkaEE1Ly9sdGxRV0ZsN2ZFCkRzeUpQb05tQmhzSy9SNEpYVDZ4Q0NqYmJmRFF6OE1hTXA0aWZnRW9ac0R6T2RlK3ZDL3diMEcxVmlpL1FjOEEKSCtSb2tJUkI2MTZqM0VjOWhsd1V4UjNyZThqOGFFdDJob1BkbTVhekt1YjQ0LzlKc3VaU1BWR0FYVXVjekQyawpYUU5UOWErOVl4RXZJZ0psdFpuRGVYSjZmeTFqQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSVEo2WWgwQ3lWVDRGNEhJUSszYWVhQzZzMUlUQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1pZM0xuUDl4Qgp1MjJaMENtazdiNUI2T1RtRS9obWlNRDNXY3kyb3RpcVhvZUE1VENRWnZxUk1PTk1NR3NCZFYza3FRRFhyaVR1CkQ4MDdaL3Q3SlAvOGo1RmRncDBCbkpoOUtlQkhaeVBybWFQNW9veFg4VWhFZHF0bWdsTUtBSk0xVmpKTExZNUwKMUcyRVNWa09NKytTSkV5MGJMbU9LM3M2YUI1L05pK3BVVS82Z1ZFNDFIZnh1SEJVYUtrRXNJR1d0WnNxbEY1cwp1RVAzZnY0ZmJRZVAxTmEvRlNaSmh4NlBybEdjZlE2Vmh6a1haY2Q1RExKMHZHbHZoTGdwREowdUVsUEd6NU5KCldFelVJZ3BGV25UMUd4TlhuNm02Sm9oMmNoWU5oQ25KOGZCS0Q4elozei9LdExCa2JwMDdMRlgwbzhXQUhEQmcKK1A4cjUwTm5IT3FHCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJWUlIcnhuOXYvOTR3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHlOakV3TXpBd016VXhOVE5hTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEd0NGWW0KY1JldG5xWjJBR21FUGJ2L1pRVzdrSzFKNHlBUmI2ODVlNEl5QjQ2OXdKOFVtd1crOXB2OWNsVm5YV3pnQkY3WQpnbkIyNi9DTWtqOVpnRkhOaWFPK3RXcXg3cHJKTkdDaHhiY29VMDZzQUIwR3MvUkVHK3VYMnFZa3RnVHpRNWFrCitGKzZrZElRek5VdnpwWFUzUFlHcDFEcGlzNWxZNFYzMkhnSkRaZkMrRzlpT1ROd1dtTzV3bGF1K1lsQkRGTVIKS2tnVFo1MDY5OXl5NWxnUlRoaTczSG1hUCtLWGdIT0QrNkNmeUZ6Ty80KzdLaExjanZpTGFUVjBjNGkzYkxidQo0K0llU2pwMEpxU2lxQlFtRHhHRitYMndCSkNiRVZObWJrd0hCVlh5eXlxdGJWV2dibEN6SWJ0UDBadHE3RUMwClo0WkNDemc5RFNqRGQwZWZBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkZNbnBpSFFMSlZQZ1hnYwpoRDdkcDVvTHF6VWhNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUFzTHJBMEhFOVNGNHAvSzBQejlVdFZLdk9rCjNUaEZ0ODZGTGlWNEJMcTZ5RSt1aHdHazk0b3p1Y3c1T2h1WEduTWFaUlFMYnliS3pJcjQvUUNqQVQ5eFVURWQKSFQ4c1c1UEhHMm5lbGJRckFNdVhRaFpXdlZTRmZ6Tk5GZG0rNStzdnVXajVtMklyNXNYRURlV2dBdmNLd3k2cwpVUjIxSmdtVXZHSFFtTVVZYWpnYW8wS3NjQmtNOEpZekFKdXZWdkJtTytwdzN5T2hVVmMyY0JnV0gybmx3L3RLCjZRR0Y0ZUZPRnJaYzM5UHp2NmlVOHFBYnNrQlVTVlhuaXg3dTNZUzFwTHNuZitSY0U0MmR1RzV4Nll3UFBlb28KRXBwWVluZ1R5TlpKKzVGaHVZdTUwMDJsQm1DV3JrSkxEek5NWlR3ai9DeG52ekVnSWJPWFpndnRpSXhpCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K",
KeyData: "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBOEFoV0puRVhyWjZtZGdCcGhEMjcvMlVGdTVDdFNlTWdFVyt2T1h1Q01nZU92Y0NmCkZKc0Z2dmFiL1hKVloxMXM0QVJlMklKd2R1dndqSkkvV1lCUnpZbWp2clZxc2U2YXlUUmdvY1czS0ZOT3JBQWQKQnJQMFJCdnJsOXFtSkxZRTgwT1dwUGhmdXBIU0VNelZMODZWMU56MkJxZFE2WXJPWldPRmQ5aDRDUTJYd3ZodgpZamt6Y0ZwanVjSldydm1KUVF4VEVTcElFMmVkT3ZmY3N1WllFVTRZdTl4NW1qL2lsNEJ6Zy91Z244aGN6ditQCnV5b1MzSTc0aTJrMWRIT0l0MnkyN3VQaUhrbzZkQ2Frb3FnVUpnOFJoZmw5c0FTUW14RlRabTVNQndWVjhzc3EKclcxVm9HNVFzeUc3VDlHYmF1eEF0R2VHUWdzNFBRMG93M2RIbndJREFRQUJBb0lCQUFxSWt4OUV2MEZEUVJMVQptY3pQMkx3d2RydndjV3BZcVVPYW54bnFyWi84Yk9zdTFNeFdzVDNjSEtSV3JDREpITW9INXhHaFI4WXdQSEl1CnlORG9ySzVVWi9jcWh2QWdCSExuOVlXajQ1SEZkaUplTHVmb1pjUEhaZU5ZR1FwclluUTZkeFh1UUdVem1RQmIKdk05SVJaTDl6MTRqWVkyZUpjaVZRWG9zNmJlYjUxYjgxNGljMTg1RHNtK2RhekRuNG14M2tNT0lueFR2K01pNQpxSWx5OU8vQURIaWpNd2taNVY5K3grSlpxM3Exc09SeTBKcUUwd1czbFcwQnFxSWRGRFRSelAvMFdiVGZZdDU3CmlRNjJySnhEN1RGNzR3Ni8xc3VqalU3Y2VsK1ltdTRvRFZjb05pOGdoTE1UZXE1OWpPMk1xR1FqMU5HUHRuTHkKb0hFOUs4RUNnWUVBOVRiQ3VEUlBtVDFmN0MwUldYUkJnejlENWhhRExkaS82aitjMGx5amR0TjkyR2JHdFNFMQozVVIvc2dsRit3bVliWmJmNExqUnpibnNZTGFleHRtakpzWXdFK0t4SSt3SEloSElPRFFaSTBaT08vMTJYdm1oCjB4dDdUNmNTVTZZSHZEbkp4WkpFaGt3TjBwL1ZoSHZMZFZMWmd3ZnNtQWlVekNTTVBmaUkySmtDZ1lFQStwYzcKTUJ0ZFNBZnd5cElMaUR6dis2WjFBQnVrWUphWnFQTk9IRGdLeElRNVJEQVZ5K3hSQXJWQ1V3RE5WdDJtTGJHUQpHZysvWXl4ZllEd2dSYTIxMUJDL0pUU3E4S1dHYVdXM0h2Z0VmMk54cVVIckNkT3VGZGhqdWkrMlRBdEdBb0w1CjluSGx3TXBZVVpydjF6dENCRmx4L1ZYd3NxUGZ6K2l5ZG1CVUxQY0NnWUVBcFM5Q2RMd29jdDQ1WSt2b0tBNTgKbzJGVzZBUjZVY1FWWkVOOTdPZWk1a1VLSFdEK3NyMndmMkhKYzdGemh1eXIxZ2N3d1QwL2VBcXJCV3VBQWd4UwpMNmlLY3ByZklZZTZObVVzTDFCSkxzNEpuYmZjcVpZWVFSSGVPNFljZm1UMkNRSVV2aGNPT2ptNWhnMU4xSFZnClZhUitDaHFvY3JJMUtsL2thVXFuUk9FQ2dZRUF5ZWx0RVhnYkUxMENrZFpYWUhEcFZUVnNkS2ZSTE5wcitZd0IKMWc3NTdobzBJbE0wWE5tTzlNV2tLVWt1S3QzeGRrUHFQbldOMnBUNFRJeGwzSDc1VVdRbEFBK041TlVhbG5ZVQp0T2xXaG1aVVFQTVNOUnJRM0YwOURkby80c242b1M5enhUVkUwTEM1dFJkSVJYNUQxVWxVNWJHSGZnazQzMGM1CjlOUHRQMFVDZ1lFQXk1L05hZXJlZDlQSDcyVzNDNW1UQy9jbEQxdUdmZXdPVkFkdko1eldlMDh4Q01CcEpya1QKU3dKM3NZOXYyaEdwSUxYZnU5YnppL0RWaW1sZk5MNkZBV2VaR3BCYm1qTHBEcUxWRzdhcUNHQVcvRG9iNmVlWApweEFiQTBLaUhoaE9sdUdONHdkbFdQRzNWdTlZNXZIb3RBNW1iZlRpaHhUYTlEZWRkZXlkNC9RPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=",
},
},
Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0,
Users: userSeeds,
Registries: registrySeeds,
Clusters: clusterSeeds,
}
}
// sanitizeEnvKey converts "my-cluster" to "MY_CLUSTER" for env var names.
func sanitizeEnvKey(name string) string {
s := strings.Map(func(r rune) rune {
if r == '-' || r == ' ' {
return '_'
}
return r
}, name)
return strings.ToUpper(s)
}

View File

@ -1,9 +1,12 @@
package entity
import (
"log"
"strings"
"time"
"unicode"
"gopkg.in/yaml.v3"
)
// InstanceStatus 实例状态
@ -103,9 +106,31 @@ func (i *Instance) SetValues(values map[string]interface{}) {
i.UpdatedAt = time.Now()
}
// SetValuesYAML 设置 YAML 格式的 Values
func (i *Instance) SetValuesYAML(yaml string) {
i.ValuesYAML = yaml
// SetValuesYAML 设置 YAML 格式的 Values 并解析到 Values map
func (i *Instance) SetValuesYAML(yamlStr string) {
i.ValuesYAML = yamlStr
if yamlStr == "" {
return
}
// 解析 YAML 到 map确保 Helm 客户端能正确使用
var parsed map[string]interface{}
if err := yaml.Unmarshal([]byte(yamlStr), &parsed); err != nil {
log.Printf("[SetValuesYAML] WARNING: failed to parse YAML for instance %s: %s, yaml=%q", i.Name, err, yamlStr)
return
}
if parsed == nil {
return
}
// Merge into existing Values (user-provided takes precedence)
if i.Values == nil {
i.Values = make(map[string]interface{})
}
for k, v := range parsed {
// Only set if not already present (Values map takes precedence over YAML fallback)
if _, exists := i.Values[k]; !exists {
i.Values[k] = v
}
}
i.UpdatedAt = time.Now()
}

View File

@ -43,6 +43,7 @@ type HostPathConfig struct {
type StorageBackend struct {
ID string
WorkspaceID string
ClusterID string // 关联的 clusterNULL 表示 workspace/shared 级别
OwnerID string
Name string
Type StorageType
@ -70,6 +71,13 @@ func NewStorageBackend(workspaceID, ownerID, name string, storageType StorageTyp
}
}
// NewClusterStorageBackend 创建 cluster 级别的存储后端
func NewClusterStorageBackend(workspaceID, clusterID, ownerID, name string, storageType StorageType, config StorageConfig) *StorageBackend {
storage := NewStorageBackend(workspaceID, ownerID, name, storageType, config)
storage.ClusterID = clusterID
return storage
}
// Validate 验证存储后端数据
func (s *StorageBackend) Validate() error {
if s.Name == "" {

View File

@ -9,17 +9,19 @@ type Workspace struct {
ID string
Name string
Description string
CreatedBy string // 创建者用户 ID
ClusterIDs []string // 关联的集群 ID 列表
CreatedBy string // 创建者用户 ID
CreatedAt time.Time
UpdatedAt time.Time
}
// NewWorkspace 创建新工作空间
func NewWorkspace(name, description, createdBy string) *Workspace {
func NewWorkspace(name, description, createdBy string, clusterIDs []string) *Workspace {
now := time.Now()
return &Workspace{
Name: name,
Description: description,
ClusterIDs: clusterIDs,
CreatedBy: createdBy,
CreatedAt: now,
UpdatedAt: now,

View File

@ -25,6 +25,12 @@ type StorageRepository interface {
// GetDefault 获取 workspace 的默认存储后端
GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error)
// GetByCluster 获取 cluster 关联的存储后端列表
GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error)
// GetDefaultByCluster 获取 cluster 的默认存储后端
GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error)
// Update 更新存储后端
Update(ctx context.Context, storage *entity.StorageBackend) error

View File

@ -175,8 +175,8 @@ func (s *ClusterService) createRestConfig(cluster *entity.Cluster) (*rest.Config
kubeconfig = ".kube/config"
}
// 尝试从文件加载 kubeconfig
if _, err := os.Stat(kubeconfig); err == nil {
return clientcmd.BuildConfigFromFlags("", kubeconfig)
if _, err := os.Stat(kubeconfig); err != nil {
return nil, fmt.Errorf("no valid credentials found for cluster %s (no cert/key/token, and kubeconfig file not found: %s)", cluster.Name, kubeconfig)
}
return clientcmd.BuildConfigFromFlags("", kubeconfig)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"strings"
@ -16,12 +17,13 @@ import (
// 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
helmClient repository.HelmClient
ociClient repository.OCIClient
entryClient repository.InstanceEntryClient
storageService *StorageService // for layered storage config resolution
}
// NewInstanceService 创建实例服务
@ -34,15 +36,21 @@ func NewInstanceService(
entryClient repository.InstanceEntryClient,
) *InstanceService {
return &InstanceService{
instanceRepo: instanceRepo,
clusterRepo: clusterRepo,
registryRepo: registryRepo,
helmClient: helmClient,
ociClient: ociClient,
entryClient: entryClient,
instanceRepo: instanceRepo,
clusterRepo: clusterRepo,
registryRepo: registryRepo,
helmClient: helmClient,
ociClient: ociClient,
entryClient: entryClient,
storageService: nil, // set via SetStorageService for layered storage
}
}
// SetStorageService 设置存储服务(用于分层存储配置解析)
func (s *InstanceService) SetStorageService(storageService *StorageService) {
s.storageService = storageService
}
const chartCacheDir = "/tmp/charts"
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
@ -89,6 +97,20 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
return entity.ErrInstanceExists
}
// ===== 分层存储配置解析 =====
// Priority: workspace-level default > cluster-level default > shared default
if s.storageService != nil && instance.WorkspaceID != "" {
resolution, err := s.storageService.ResolveStorageConfig(ctx, instance.ClusterID, instance.WorkspaceID)
if err == nil && resolution != nil && resolution.Storage != nil {
// Merge resolved storage values into instance.Values
if instance.Values == nil {
instance.Values = make(map[string]interface{})
}
// User override takes highest priority (already set), so we only set if not already present
mergeStorageToValues(instance.Values, resolution.Storage)
}
}
instance.BeginOperation(entity.OperationInstall, "Preparing installation")
// 先写入数据库,记录 pending 状态
@ -104,7 +126,16 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
}
// 异步执行 Helm 安装并监控状态
go s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance)
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("[goroutine-panic] instanceID=%s panic=%v", instance.ID, r)
}
}()
log.Printf("[goroutine-start] instanceID=%s name=%s cluster=%s", instance.ID, instance.Name, cluster.Name)
s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance)
log.Printf("[goroutine-done] instanceID=%s", instance.ID)
}()
// 立即返回,状态同步由后台任务处理
return nil
@ -286,8 +317,10 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in
// executeAndSyncInstall 异步执行安装并监控状态
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
log.Printf("[install-start] instanceID=%s values=%v", instanceID, instance.Values)
// 执行 Helm 安装
if err := s.helmClient.Install(ctx, cluster, instance); err != nil {
log.Printf("[install-fail] instanceID=%s err=%v", instanceID, err)
// 更新实例状态为失败
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
if updateErr == nil && instance != nil {
@ -296,6 +329,7 @@ func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID
}
return
}
log.Printf("[install-ok] instanceID=%s revision=%d", instanceID, instance.Revision)
// 安装成功后,同步状态
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall)
@ -472,3 +506,48 @@ func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID str
_ = s.instanceRepo.Update(ctx, instance)
}
}
// mergeStorageToValues 将存储配置 merge 到 Helm values
// 只覆盖 nil/空的字段,保留用户已设置的 values
func mergeStorageToValues(values map[string]interface{}, storage *entity.StorageBackend) {
if storage == nil || values == nil {
return
}
persistence := make(map[string]interface{})
switch storage.Type {
case entity.StorageTypeNFS:
if storage.Config.NFS != nil {
persistence["type"] = "nfs"
persistence["nfs"] = map[string]interface{}{
"server": storage.Config.NFS.Server,
"path": storage.Config.NFS.Path,
}
// Helm common chart labels
persistence["mountOptions"] = []string{"rw", "relatime", "vers=3"}
persistence["reclaimPolicy"] = "Retain"
}
case entity.StorageTypePV:
if storage.Config.PV != nil {
persistence["type"] = "persistentVolumeClaim"
persistence["storageClass"] = storage.Config.PV.StorageClassName
persistence["size"] = storage.Config.PV.Capacity
persistence["accessMode"] = storage.Config.PV.AccessModes
}
case entity.StorageTypeHostPath:
if storage.Config.HostPath != nil {
persistence["type"] = "hostPath"
persistence["hostPath"] = map[string]interface{}{
"path": storage.Config.HostPath.Path,
}
}
}
// Only merge if key doesn't already exist and has a value
for key, val := range persistence {
if _, exists := values[key]; !exists && val != nil {
values[key] = val
}
}
}

View File

@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
"fmt"
"strings"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
@ -13,6 +15,13 @@ var (
ErrStorageExists = errors.New("storage backend already exists")
)
// StorageResolution 存储分层解析结果
type StorageResolution struct {
Storage *entity.StorageBackend // 最终选中的 storage
ValuesYAML string // 转换为 YAML 的 values
Source string // 来源: "workspace", "cluster", "shared"
}
// StorageService 存储后端领域服务
type StorageService struct {
storageRepo repository.StorageRepository
@ -33,14 +42,20 @@ func (s *StorageService) Create(
config entity.StorageConfig,
description string,
isDefault, isShared bool,
clusterID string,
) (*entity.StorageBackend, error) {
// 检查名称是否已存在
// 检查名称是否已存在(同一 workspace 或同一 cluster 下不能重复)
existing, _ := s.storageRepo.GetByName(ctx, workspaceID, name)
if existing != nil {
return nil, ErrStorageExists
}
storage := entity.NewStorageBackend(workspaceID, ownerID, name, storageType, config)
var storage *entity.StorageBackend
if clusterID != "" {
storage = entity.NewClusterStorageBackend(workspaceID, clusterID, ownerID, name, storageType, config)
} else {
storage = entity.NewStorageBackend(workspaceID, ownerID, name, storageType, config)
}
storage.Description = description
storage.IsDefault = isDefault
storage.IsShared = isShared
@ -113,4 +128,81 @@ func (s *StorageService) Delete(ctx context.Context, id string) error {
// List 列出所有存储后端(管理员用)
func (s *StorageService) List(ctx context.Context) ([]*entity.StorageBackend, error) {
return s.storageRepo.List(ctx)
}
// ResolveStorageConfig 分层解析存储配置
// 优先级User Override > Workspace Default > Cluster Default > Shared Default
func (s *StorageService) ResolveStorageConfig(
ctx context.Context,
clusterID, workspaceID string,
) (*StorageResolution, error) {
// 1. 查找 workspace-level 默认存储
if workspaceID != "" {
wsStorage, err := s.storageRepo.GetDefault(ctx, workspaceID)
if err == nil && wsStorage != nil {
yaml, _ := storageToValuesYAML(wsStorage)
return &StorageResolution{
Storage: wsStorage,
ValuesYAML: yaml,
Source: "workspace",
}, nil
}
}
// 2. 查找 cluster-level 默认存储
if clusterID != "" {
clusterStorage, err := s.storageRepo.GetDefaultByCluster(ctx, clusterID)
if err == nil && clusterStorage != nil {
yaml, _ := storageToValuesYAML(clusterStorage)
return &StorageResolution{
Storage: clusterStorage,
ValuesYAML: yaml,
Source: "cluster",
}, nil
}
}
// 3. 查找 shared 默认存储
sharedStorages, err := s.storageRepo.GetShared(ctx)
if err == nil {
for _, s := range sharedStorages {
if s.IsDefault {
yaml, _ := storageToValuesYAML(s)
return &StorageResolution{
Storage: s,
ValuesYAML: yaml,
Source: "shared",
}, nil
}
}
}
return nil, nil
}
// storageToValuesYAML 将 storage config 转换为 values.yaml 格式
func storageToValuesYAML(storage *entity.StorageBackend) (string, error) {
if storage == nil {
return "", nil
}
switch storage.Type {
case entity.StorageTypeNFS:
if storage.Config.NFS != nil {
// Format as nfs-server/path storageClass so Helm charts like bitnami/nginx
// can use: persistence.storageClass: "nfs-server/path"
nfsSC := fmt.Sprintf("nfs-%s-%s", storage.Config.NFS.Server, strings.TrimPrefix(storage.Config.NFS.Path, "/"))
return fmt.Sprintf("persistence:\n enabled: true\n storageClass: \"%s\"\n existingClaim: \"\"\n mountOptions:\n - hard\n - nfsvers=4.1\n dataSource: {}\n# NFS Server: %s\n# NFS Path: %s", nfsSC, storage.Config.NFS.Server, storage.Config.NFS.Path), nil
}
case entity.StorageTypePV:
if storage.Config.PV != nil {
return fmt.Sprintf("persistence:\n enabled: true\n storageClass: \"%s\"\n size: %s\n accessModes: %v\n existingClaim: \"\"", storage.Config.PV.StorageClassName, storage.Config.PV.Capacity, storage.Config.PV.AccessModes), nil
}
case entity.StorageTypeHostPath:
if storage.Config.HostPath != nil {
return fmt.Sprintf("persistence:\n enabled: true\n hostPath: \"%s\"\n existingClaim: \"\"", storage.Config.HostPath.Path), nil
}
}
return "", nil
}

View File

@ -26,19 +26,31 @@ func NewWorkspaceService(
}
}
// Create 创建工作空间
func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string) (*entity.Workspace, error) {
// Create 创建工作空间(支持 cluster_ids 和初始配额)
func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string, clusterIDs []string, quotas map[entity.ResourceType]struct {
HardLimit float64
SoftLimit float64
}) (*entity.Workspace, error) {
// 检查名称是否已存在
existing, _ := s.workspaceRepo.GetByName(ctx, name)
if existing != nil {
return nil, entity.ErrWorkspaceExists
}
workspace := entity.NewWorkspace(name, description, createdBy)
workspace := entity.NewWorkspace(name, description, createdBy, clusterIDs)
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
return nil, err
}
// 如果提供了配额,创建它们
for resourceType, config := range quotas {
quota := entity.NewWorkspaceQuota(workspace.ID, resourceType, config.HardLimit, config.SoftLimit)
if err := s.quotaRepo.Create(ctx, quota); err != nil {
// 记录错误但不阻止工作空间创建
continue
}
}
return workspace, nil
}