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