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:
@ -156,6 +156,9 @@ func main() {
|
||||
storageService := service.NewStorageService(repos.StorageRepo)
|
||||
storageHandler := rest.NewStorageHandler(storageService)
|
||||
|
||||
// Wire storage service into instance service for layered storage config
|
||||
instanceService.SetStorageService(storageService)
|
||||
|
||||
// Chart Reference Handler
|
||||
chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo)
|
||||
chartRefHandler := rest.NewChartReferenceHandler(chartRefService)
|
||||
@ -377,6 +380,7 @@ func setupRouter(
|
||||
// ===== Storage Backend 路由 =====
|
||||
api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost)
|
||||
api.HandleFunc("/storage-backends", storageHandler.GetAllStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/resolve", storageHandler.ResolveStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.GetStorage).Methods(http.MethodGet)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut)
|
||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete)
|
||||
|
||||
@ -61,19 +61,37 @@ services:
|
||||
image: ocdp-backend:latest
|
||||
container_name: ocdp-backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- /media/ivanwu/DATA/ocdp-go/.env
|
||||
environment:
|
||||
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||
PORT: 8080
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||
KUBECONFIG: ${KUBECONFIG:-.kube/config}
|
||||
HARBOR_URL: ${HARBOR_URL:-}
|
||||
HARBOR_USERNAME: ${HARBOR_USERNAME:-}
|
||||
HARBOR_PASSWORD: ${HARBOR_PASSWORD:-}
|
||||
NFS_SERVER: ${NFS_SERVER:-}
|
||||
NFS_SHARE: ${NFS_SHARE:-}
|
||||
KUBECONFIG: ""
|
||||
ALLOWED_DEV_ORIGINS: ${ALLOWED_DEV_ORIGINS:-*}
|
||||
# Bootstrap data (loaded from .env via env_file above)
|
||||
BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-}
|
||||
BOOTSTRAP_ADMIN_PASS: ${BOOTSTRAP_ADMIN_PASS:-}
|
||||
BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-}
|
||||
BOOTSTRAP_REGISTRY_NAME: ${BOOTSTRAP_REGISTRY_NAME:-}
|
||||
BOOTSTRAP_REGISTRY_URL: ${BOOTSTRAP_REGISTRY_URL:-}
|
||||
BOOTSTRAP_REGISTRY_DESC: ${BOOTSTRAP_REGISTRY_DESC:-}
|
||||
BOOTSTRAP_REGISTRY_USER: ${BOOTSTRAP_REGISTRY_USER:-}
|
||||
BOOTSTRAP_REGISTRY_PASS: ${BOOTSTRAP_REGISTRY_PASS:-}
|
||||
BOOTSTRAP_REGISTRY_INSECURE: ${BOOTSTRAP_REGISTRY_INSECURE:-}
|
||||
BOOTSTRAP_CLUSTERS: ${BOOTSTRAP_CLUSTERS:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER1_HOST:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER1_DESC:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CA: ${BOOTSTRAP_CLUSTER_CLUSTER1_CA:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER1_CERT:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER1_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER1_KEY:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER2_HOST:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER2_DESC:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_CA: ${BOOTSTRAP_CLUSTER_CLUSTER2_CA:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER2_CERT:-}
|
||||
BOOTSTRAP_CLUSTER_CLUSTER2_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER2_KEY:-}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 存储配置(脱敏后)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
})
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(), "")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 列出所有存储(管理员用)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -43,6 +43,7 @@ type HostPathConfig struct {
|
||||
type StorageBackend struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
ClusterID string // 关联的 cluster,NULL 表示 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 == "" {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -142,6 +142,8 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_name ON workspaces(name);
|
||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
workspace_id VARCHAR(36),
|
||||
owner_id VARCHAR(36),
|
||||
cluster_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
config JSONB NOT NULL,
|
||||
@ -154,6 +156,8 @@ CREATE TABLE IF NOT EXISTS storage_backends (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_cluster ON storage_backends(cluster_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_default_cluster ON storage_backends(cluster_id, is_default) WHERE cluster_id IS NOT NULL;
|
||||
|
||||
-- ===== Chart References 表 =====
|
||||
CREATE TABLE IF NOT EXISTS chart_references (
|
||||
@ -183,7 +187,7 @@ CREATE TABLE IF NOT EXISTS values_templates (
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, chart_reference_id, name)
|
||||
UNIQUE(workspace_id, chart_reference_id, name, version)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id);
|
||||
|
||||
15
backend/scripts/migrations/20250418_add_cluster_storage.sql
Normal file
15
backend/scripts/migrations/20250418_add_cluster_storage.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- Migration: Add cluster_id to storage_backends for layered storage config
|
||||
-- This enables storage backends to be associated with specific clusters
|
||||
-- Priority order: User Override > Workspace Default > Cluster Default > Shared Storage
|
||||
|
||||
-- Add cluster_id column to storage_backends table
|
||||
ALTER TABLE storage_backends ADD COLUMN IF NOT EXISTS cluster_id VARCHAR(36) REFERENCES clusters(id) ON DELETE SET NULL;
|
||||
|
||||
-- Index for faster lookups by cluster
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_backends_cluster ON storage_backends(cluster_id);
|
||||
|
||||
-- Composite index for cluster + default storage lookup
|
||||
CREATE INDEX IF NOT EXISTS idx_storage_backends_default_cluster ON storage_backends(cluster_id, is_default) WHERE cluster_id IS NOT NULL;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN storage_backends.cluster_id IS 'Associated cluster ID for cluster-level default storage. NULL means workspace or shared storage.';
|
||||
Reference in New Issue
Block a user