feat(frontend): add Helm chart browser, monitoring, chart-references and values templates pages

Add new frontend pages for the multi-tenant OCDP platform:

- Charts page (/charts): Browse Harbor OCI registries to list Helm chart repositories
  and versions, with deploy modal to launch charts on selected clusters
- Monitoring page (/monitoring): Display cluster metrics (CPU/Memory/GPU usage)
  and per-node details with resource utilization bars
- Chart References page (/chart-references): CRUD for chart metadata references
- Values Templates page (/templates): CRUD for Helm values templates with version
  history and rollback support
- Sidebar: Add Charts navigation, update Storage and Templates links
- api.ts: Add all API client functions (clusterApi, registryApi, instanceApi,
  monitoringApi, storageApi, chartReferenceApi, valuesTemplateApi,
  workspaceApi, userApi) with full TypeScript types

Note: deploy flow and values template rollback not yet end-to-end tested.
This commit is contained in:
Ivan087
2026-04-15 16:59:31 +08:00
parent c5e51ed069
commit 29d0310f03
283 changed files with 24658 additions and 36038 deletions

View File

@ -127,6 +127,69 @@ func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
return k8s.NewEntryClient()
}
// CreateWorkspaceRepository 创建 Workspace 仓储
func (f *AdapterFactory) CreateWorkspaceRepository() (repository.WorkspaceRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("workspace repository mock not implemented")
}
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewWorkspaceRepository(f.db), nil
}
// CreateQuotaRepository 创建 Quota 仓储
// CreateStorageRepository 创建存储后端仓储
func (f *AdapterFactory) CreateStorageRepository() (repository.StorageRepository, error) {
if f.mode == ModeMock {
return mock.NewStorageRepositoryMock(), nil
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewStorageRepository(f.db), nil
}
// CreateQuotaRepository 创建配额仓储
func (f *AdapterFactory) CreateQuotaRepository() (repository.QuotaRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("quota repository mock not implemented")
}
// 默认真实实现PostgreSQL
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewQuotaRepository(f.db), nil
}
// CreateChartReferenceRepository 创建 Chart 引用仓储
func (f *AdapterFactory) CreateChartReferenceRepository() (repository.ChartReferenceRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("chart reference repository mock not implemented")
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewChartReferenceRepository(f.db), nil
}
// CreateValuesTemplateRepository 创建 Values 模板仓储
func (f *AdapterFactory) CreateValuesTemplateRepository() (repository.ValuesTemplateRepository, error) {
if f.mode == ModeMock {
return nil, fmt.Errorf("values template repository mock not implemented")
}
if err := f.ensureDBConnection(); err != nil {
return nil, err
}
return postgres.NewValuesTemplateRepository(f.db), nil
}
// CreateAllRepositories 一次性创建所有 Repositories
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
userRepo, err := f.CreateUserRepository()
@ -149,6 +212,21 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
return nil, fmt.Errorf("failed to create instance repository: %w", err)
}
workspaceRepo, err := f.CreateWorkspaceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create workspace repository: %w", err)
}
storageRepo, err := f.CreateStorageRepository()
if err != nil {
return nil, fmt.Errorf("failed to create storage repository: %w", err)
}
quotaRepo, err := f.CreateQuotaRepository()
if err != nil {
return nil, fmt.Errorf("failed to create quota repository: %w", err)
}
ociClient, err := f.CreateOCIClient()
if err != nil {
return nil, fmt.Errorf("failed to create OCI client: %w", err)
@ -163,28 +241,48 @@ func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
metricsClient := f.CreateMetricsClient(clusterRepo)
entryClient := f.CreateEntryClient()
chartRefRepo, err := f.CreateChartReferenceRepository()
if err != nil {
return nil, fmt.Errorf("failed to create chart reference repository: %w", err)
}
valuesTemplateRepo, err := f.CreateValuesTemplateRepository()
if err != nil {
return nil, fmt.Errorf("failed to create values template repository: %w", err)
}
return &Repositories{
UserRepo: userRepo,
ClusterRepo: clusterRepo,
RegistryRepo: registryRepo,
InstanceRepo: instanceRepo,
OCIClient: ociClient,
HelmClient: helmClient,
MetricsClient: metricsClient,
EntryClient: entryClient,
UserRepo: userRepo,
ClusterRepo: clusterRepo,
RegistryRepo: registryRepo,
InstanceRepo: instanceRepo,
WorkspaceRepo: workspaceRepo,
StorageRepo: storageRepo,
ChartRefRepo: chartRefRepo,
ValuesTemplateRepo: valuesTemplateRepo,
QuotaRepo: quotaRepo,
OCIClient: ociClient,
HelmClient: helmClient,
MetricsClient: metricsClient,
EntryClient: entryClient,
}, nil
}
// Repositories 所有仓储的集合
type Repositories struct {
UserRepo repository.UserRepository
ClusterRepo repository.ClusterRepository
RegistryRepo repository.RegistryRepository
InstanceRepo repository.InstanceRepository
OCIClient repository.OCIClient
HelmClient repository.HelmClient
MetricsClient repository.MetricsClient
EntryClient repository.InstanceEntryClient
UserRepo repository.UserRepository
ClusterRepo repository.ClusterRepository
RegistryRepo repository.RegistryRepository
InstanceRepo repository.InstanceRepository
WorkspaceRepo repository.WorkspaceRepository
StorageRepo repository.StorageRepository
ChartRefRepo repository.ChartReferenceRepository
ValuesTemplateRepo repository.ValuesTemplateRepository
QuotaRepo repository.QuotaRepository
OCIClient repository.OCIClient
HelmClient repository.HelmClient
MetricsClient repository.MetricsClient
EntryClient repository.InstanceEntryClient
}
// ensureDBConnection 确保数据库连接已建立

View File

@ -262,12 +262,40 @@ func (c *OCIClientMock) GetValuesSchema(ctx context.Context, registry *entity.Re
return mockSchema, nil
}
func (c *OCIClientMock) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
artifact, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil {
return "", err
}
if !artifact.IsChart() {
return "", fmt.Errorf("not a helm chart")
}
// 返回 Mock values.yaml
mockValues := `# Default values for the chart
replicaCount: 1
image:
repository: nginx
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
resources: {}
`
return mockValues, nil
}
func (c *OCIClientMock) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
_, err := c.GetArtifact(ctx, registry, repository, reference)
if err != nil {
return err
}
// Mock 实现,不实际下载
return nil
}

View File

@ -43,13 +43,26 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
return nil, fmt.Errorf("failed to create registry client: %w", err)
}
// 设置认证
if reg.Username != "" && reg.Password != "" {
// 设置认证 - 优先使用 registry 自己的凭证,否则使用 .env 中的默认凭证
username := reg.Username
password := reg.Password
// 如果没有提供凭证,尝试从环境变量加载
if (username == "" || password == "") && strings.Contains(reg.URL, "harbor") {
if envUser := os.Getenv("HARBOR_USERNAME"); envUser != "" {
username = envUser
}
if envPass := os.Getenv("HARBOR_PASSWORD"); envPass != "" {
password = envPass
}
}
if username != "" && password != "" {
registry.Client = &auth.Client{
Client: c.httpClient,
Credential: auth.StaticCredential(registryURL, auth.Credential{
Username: reg.Username,
Password: reg.Password,
Username: username,
Password: password,
}),
}
}
@ -370,6 +383,105 @@ func (c *OCIClient) GetValuesSchema(ctx context.Context, registry *entity.Regist
return "", entity.ErrValuesSchemaNotFound
}
// GetValues 获取 Helm Chart 的 values.yaml
func (c *OCIClient) GetValues(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error) {
reg, err := c.getRegistry(registry)
if err != nil {
return "", err
}
repo, err := reg.Repository(ctx, repository)
if err != nil {
return "", fmt.Errorf("failed to get repository: %w", err)
}
// 解析 reference (tag 或 digest)
desc, err := repo.Resolve(ctx, reference)
if err != nil {
return "", fmt.Errorf("failed to resolve artifact: %w", err)
}
manifestReader, err := repo.Fetch(ctx, desc)
if err != nil {
return "", fmt.Errorf("failed to fetch manifest: %w", err)
}
defer manifestReader.Close()
manifestBytes, err := io.ReadAll(manifestReader)
if err != nil {
return "", fmt.Errorf("failed to read manifest: %w", err)
}
var manifest ocispec.Manifest
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return "", fmt.Errorf("failed to unmarshal manifest: %w", err)
}
// 查找 Helm Chart layertar+gzip 包含 chart 内容)并从中读取 values.yaml
var chartLayer *ocispec.Descriptor
for i := range manifest.Layers {
layer := manifest.Layers[i]
if strings.Contains(layer.MediaType, "cncf.helm.chart") ||
strings.Contains(layer.MediaType, "helm.chart.content") {
chartLayer = &manifest.Layers[i]
break
}
}
if chartLayer == nil {
return "", entity.ErrValuesNotFound
}
if chartLayer.Digest == "" {
return "", fmt.Errorf("chart layer digest is empty")
}
if _, err := digest.Parse(string(chartLayer.Digest)); err != nil {
return "", fmt.Errorf("invalid chart layer digest: %w", err)
}
layerReader, err := repo.Fetch(ctx, *chartLayer)
if err != nil {
return "", fmt.Errorf("failed to fetch chart layer: %w", err)
}
defer layerReader.Close()
gzipReader, err := gzip.NewReader(layerReader)
if err != nil {
return "", fmt.Errorf("failed to create gzip reader: %w", err)
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return "", fmt.Errorf("failed to read chart archive: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
// 查找 values.yaml 文件(可能在 chart 根目录或子目录中)
// 通常路径格式为: {chart-name}/values.yaml
if strings.HasSuffix(header.Name, "values.yaml") {
data, err := io.ReadAll(tarReader)
if err != nil {
return "", fmt.Errorf("failed to read values.yaml: %w", err)
}
if len(data) == 0 {
return "", entity.ErrValuesNotFound
}
return string(data), nil
}
}
return "", entity.ErrValuesNotFound
}
// PullArtifact 下载 artifact 到本地
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error {
reg, err := c.getRegistry(registry)

View File

@ -104,13 +104,41 @@ func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *ClusterRepositoryMock) List(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0, len(r.clusters))
for _, cluster := range r.clusters {
// 解密敏感数据后返回
clusters = append(clusters, r.decryptCluster(cluster))
}
return clusters, nil
}
func (r *ClusterRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0)
for _, cluster := range r.clusters {
if cluster.WorkspaceID == workspaceID {
clusters = append(clusters, r.decryptCluster(cluster))
}
}
return clusters, nil
}
func (r *ClusterRepositoryMock) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
r.mu.RLock()
defer r.mu.RUnlock()
clusters := make([]*entity.Cluster, 0)
for _, cluster := range r.clusters {
if cluster.IsShared {
clusters = append(clusters, r.decryptCluster(cluster))
}
}
return clusters, nil
}

View File

@ -102,12 +102,26 @@ func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID st
func (r *InstanceRepositoryMock) List(ctx context.Context) ([]*entity.Instance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0, len(r.instances))
for _, instance := range r.instances {
instances = append(instances, instance)
}
return instances, nil
}
func (r *InstanceRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
r.mu.RLock()
defer r.mu.RUnlock()
instances := make([]*entity.Instance, 0)
for _, instance := range r.instances {
if instance.WorkspaceID == workspaceID {
instances = append(instances, instance)
}
}
return instances, nil
}

View File

@ -0,0 +1,96 @@
package mock
import (
"context"
"github.com/ocdp/cluster-service/internal/domain/entity"
)
// StorageRepositoryMock Storage 仓储 Mock
type StorageRepositoryMock struct {
storages map[string]*entity.StorageBackend
}
// NewStorageRepositoryMock 创建 Mock
func NewStorageRepositoryMock() *StorageRepositoryMock {
return &StorageRepositoryMock{
storages: make(map[string]*entity.StorageBackend),
}
}
// Create 创建存储
func (r *StorageRepositoryMock) Create(ctx context.Context, storage *entity.StorageBackend) error {
r.storages[storage.ID] = storage
return nil
}
// GetByID 获取存储
func (r *StorageRepositoryMock) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
if s, ok := r.storages[id]; ok {
return s, nil
}
return nil, entity.ErrStorageNotFound
}
// GetByWorkspace 获取工作空间的存储
func (r *StorageRepositoryMock) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
if s.WorkspaceID == workspaceID {
result = append(result, s)
}
}
return result, nil
}
// GetByName 按名称获取
func (r *StorageRepositoryMock) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
for _, s := range r.storages {
if s.WorkspaceID == workspaceID && s.Name == name {
return s, nil
}
}
return nil, entity.ErrStorageNotFound
}
// Update 更新存储
func (r *StorageRepositoryMock) Update(ctx context.Context, storage *entity.StorageBackend) error {
r.storages[storage.ID] = storage
return nil
}
// Delete 删除存储
func (r *StorageRepositoryMock) Delete(ctx context.Context, id string) error {
delete(r.storages, id)
return nil
}
// GetShared 获取所有共享存储后端
func (r *StorageRepositoryMock) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
if s.IsShared {
result = append(result, s)
}
}
return result, nil
}
// GetDefault 获取 workspace 的默认存储后端
func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
for _, s := range r.storages {
if s.WorkspaceID == workspaceID && s.IsDefault {
return s, nil
}
}
return nil, entity.ErrStorageNotFound
}
// List 列出所有存储(管理员用)
func (r *StorageRepositoryMock) List(ctx context.Context) ([]*entity.StorageBackend, error) {
var result []*entity.StorageBackend
for _, s := range r.storages {
result = append(result, s)
}
return result, nil
}

View File

@ -88,12 +88,40 @@ func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
func (r *UserRepositoryMock) List(ctx context.Context) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0, len(r.users))
for _, user := range r.users {
users = append(users, user)
}
return users, nil
}
func (r *UserRepositoryMock) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0)
for _, user := range r.users {
if user.WorkspaceID == workspaceID {
users = append(users, user)
}
}
return users, nil
}
func (r *UserRepositoryMock) ListActive(ctx context.Context) ([]*entity.User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
users := make([]*entity.User, 0)
for _, user := range r.users {
if user.IsActive {
users = append(users, user)
}
}
return users, nil
}

View File

@ -0,0 +1,200 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// AuditLogRepository PostgreSQL 审计日志仓储实现
type AuditLogRepository struct {
db *DB
}
// NewAuditLogRepository 创建 PostgreSQL 审计日志仓储
func NewAuditLogRepository(db *DB) repository.AuditLogRepository {
return &AuditLogRepository{db: db}
}
// Create 创建审计日志
func (r *AuditLogRepository) Create(ctx context.Context, log *entity.AuditLog) error {
if log.ID == "" {
log.ID = uuid.New().String()
}
detailsJSON, err := json.Marshal(log.Details)
if err != nil {
return fmt.Errorf("failed to marshal details: %w", err)
}
query := `
INSERT INTO audit_logs (id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err = r.db.conn.ExecContext(ctx, query,
log.ID,
log.WorkspaceID,
log.UserID,
log.Action,
log.ResourceType,
log.ResourceID,
log.ResourceName,
detailsJSON,
log.IPAddress,
log.UserAgent,
log.CreatedAt,
)
if err != nil {
return fmt.Errorf("failed to create audit log: %w", err)
}
return nil
}
// GetByWorkspace 获取 workspace 的审计日志
func (r *AuditLogRepository) GetByWorkspace(ctx context.Context, workspaceID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE workspace_id = $1
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// GetByUser 获取用户的审计日志
func (r *AuditLogRepository) GetByUser(ctx context.Context, userID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`
rows, err := r.db.conn.QueryContext(ctx, query, userID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// GetByResource 获取资源的审计日志
func (r *AuditLogRepository) GetByResource(ctx context.Context, resourceType entity.AuditResourceType, resourceID string, limit int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
query := `
SELECT id, workspace_id, user_id, admin action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
WHERE resource_type = $1 AND resource_id = $2
ORDER BY created_at DESC
LIMIT $3
`
rows, err := r.db.conn.QueryContext(ctx, query, resourceType, resourceID, limit)
if err != nil {
return nil, fmt.Errorf("failed to get audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// List 列出审计日志(分页)
func (r *AuditLogRepository) List(ctx context.Context, limit, offset int) ([]*entity.AuditLog, error) {
if limit <= 0 {
limit = 100
}
if offset < 0 {
offset = 0
}
query := `
SELECT id, workspace_id, user_id, action, resource_type, resource_id, resource_name, details, ip_address, user_agent, created_at
FROM audit_logs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
rows, err := r.db.conn.QueryContext(ctx, query, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list audit logs: %w", err)
}
defer rows.Close()
return r.scanAuditLogs(rows)
}
// DeleteByWorkspace 删除 workspace 的审计日志
func (r *AuditLogRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
query := `DELETE FROM audit_logs WHERE workspace_id = $1`
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
if err != nil {
return fmt.Errorf("failed to delete audit logs: %w", err)
}
return nil
}
// scanAuditLogs 扫描多行结果
func (r *AuditLogRepository) scanAuditLogs(rows *sql.Rows) ([]*entity.AuditLog, error) {
logs := make([]*entity.AuditLog, 0)
for rows.Next() {
log := &entity.AuditLog{}
var detailsJSON []byte
err := rows.Scan(
&log.ID,
&log.WorkspaceID,
&log.UserID,
&log.Action,
&log.ResourceType,
&log.ResourceID,
&log.ResourceName,
&detailsJSON,
&log.IPAddress,
&log.UserAgent,
&log.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan audit log: %w", err)
}
if err := json.Unmarshal(detailsJSON, &log.Details); err != nil {
log.Details = make(map[string]interface{})
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return logs, nil
}

View File

@ -0,0 +1,253 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// ChartReferenceRepository PostgreSQL Chart 引用仓储实现
type ChartReferenceRepository struct {
db *DB
}
// NewChartReferenceRepository 创建 PostgreSQL Chart 引用仓储
func NewChartReferenceRepository(db *DB) repository.ChartReferenceRepository {
return &ChartReferenceRepository{db: db}
}
// Create 创建 Chart 引用
func (r *ChartReferenceRepository) Create(ctx context.Context, chartRef *entity.ChartReference) error {
if chartRef.ID == "" {
chartRef.ID = uuid.New().String()
}
query := `
INSERT INTO chart_references (id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`
_, err := r.db.conn.ExecContext(ctx, query,
chartRef.ID,
chartRef.WorkspaceID,
chartRef.RegistryID,
chartRef.Repository,
chartRef.ChartName,
chartRef.Description,
chartRef.IsEnabled,
chartRef.CreatedAt,
chartRef.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create chart reference: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Chart 引用
func (r *ChartReferenceRepository) GetByID(ctx context.Context, id string) (*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE id = $1
`
chartRef := &entity.ChartReference{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrChartReferenceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get chart reference: %w", err)
}
return chartRef, nil
}
// GetByWorkspace 获取 workspace 的所有 Chart 引用
func (r *ChartReferenceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE workspace_id = $1
ORDER BY chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// GetByRegistry 获取 registry 的所有 Chart 引用
func (r *ChartReferenceRepository) GetByRegistry(ctx context.Context, registryID string) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE registry_id = $1
ORDER BY chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query, registryID)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// GetByName 根据名称获取 Chart 引用
func (r *ChartReferenceRepository) GetByName(ctx context.Context, workspaceID, chartName string) (*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
WHERE workspace_id = $1 AND chart_name = $2
`
chartRef := &entity.ChartReference{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartName).Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrChartReferenceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get chart reference: %w", err)
}
return chartRef, nil
}
// Update 更新 Chart 引用
func (r *ChartReferenceRepository) Update(ctx context.Context, chartRef *entity.ChartReference) error {
chartRef.UpdatedAt = time.Now()
query := `
UPDATE chart_references
SET registry_id = $1, repository = $2, chart_name = $3, description = $4, is_enabled = $5, updated_at = $6
WHERE id = $7
`
result, err := r.db.conn.ExecContext(ctx, query,
chartRef.RegistryID,
chartRef.Repository,
chartRef.ChartName,
chartRef.Description,
chartRef.IsEnabled,
chartRef.UpdatedAt,
chartRef.ID,
)
if err != nil {
return fmt.Errorf("failed to update chart reference: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrChartReferenceNotFound
}
return nil
}
// Delete 删除 Chart 引用
func (r *ChartReferenceRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM chart_references WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete chart reference: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrChartReferenceNotFound
}
return nil
}
// List 列出所有 Chart 引用(管理员用)
func (r *ChartReferenceRepository) List(ctx context.Context) ([]*entity.ChartReference, error) {
query := `
SELECT id, workspace_id, registry_id, repository, chart_name, description, is_enabled, created_at, updated_at
FROM chart_references
ORDER BY workspace_id, chart_name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list chart references: %w", err)
}
defer rows.Close()
return r.scanChartReferences(rows)
}
// scanChartReferences 扫描多行结果
func (r *ChartReferenceRepository) scanChartReferences(rows *sql.Rows) ([]*entity.ChartReference, error) {
chartRefs := make([]*entity.ChartReference, 0)
for rows.Next() {
chartRef := &entity.ChartReference{}
err := rows.Scan(
&chartRef.ID,
&chartRef.WorkspaceID,
&chartRef.RegistryID,
&chartRef.Repository,
&chartRef.ChartName,
&chartRef.Description,
&chartRef.IsEnabled,
&chartRef.CreatedAt,
&chartRef.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan chart reference: %w", err)
}
chartRefs = append(chartRefs, chartRef)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return chartRefs, nil
}

View File

@ -32,6 +32,11 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
cluster.ID = uuid.New().String()
}
// 设置默认值
if cluster.IsolationMode == "" {
cluster.IsolationMode = entity.IsolationModeNamespace
}
// 加密敏感数据
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
if err != nil {
@ -54,12 +59,14 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
}
query := `
INSERT INTO clusters (id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
INSERT INTO clusters (id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
`
_, err = r.db.conn.ExecContext(ctx, query,
cluster.ID,
cluster.WorkspaceID,
cluster.OwnerID,
cluster.Name,
cluster.Host,
encryptedCAData,
@ -67,6 +74,9 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData,
encryptedToken,
cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.CreatedAt,
cluster.UpdatedAt,
)
@ -81,7 +91,7 @@ func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster)
// GetByID 根据 ID 获取集群
func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE id = $1
`
@ -91,6 +101,8 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -98,6 +110,9 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -110,25 +125,10 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
}
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %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)
return cluster, nil
}
@ -136,7 +136,7 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu
// GetByName 根据名称获取集群
func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE name = $1
`
@ -146,6 +146,8 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&cluster.ID,
&cluster.WorkspaceID,
&cluster.OwnerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -153,6 +155,9 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&cluster.DefaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -165,25 +170,10 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity
}
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %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)
return cluster, nil
}
@ -215,9 +205,10 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
query := `
UPDATE clusters
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
token = $6, description = $7, updated_at = $8
WHERE id = $9
SET name = $1, host = $2, ca_data = $3, cert_data = $4, key_data = $5,
token = $6, description = $7, isolation_mode = $8, default_namespace = $9,
is_shared = $10, updated_at = $11
WHERE id = $12
`
result, err := r.db.conn.ExecContext(ctx, query,
@ -228,6 +219,9 @@ func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster)
encryptedKeyData,
encryptedToken,
cluster.Description,
cluster.IsolationMode,
cluster.DefaultNamespace,
cluster.IsShared,
cluster.UpdatedAt,
cluster.ID,
)
@ -272,7 +266,7 @@ func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
// List 列出所有集群
func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error) {
query := `
SELECT id, name, host, ca_data, cert_data, key_data, token, description, created_at, updated_at
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
ORDER BY created_at DESC
`
@ -283,13 +277,59 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
}
defer rows.Close()
return r.scanClusters(rows)
}
// GetByWorkspace 获取 workspace 的所有集群(包括共享集群)
func (r *ClusterRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Cluster, error) {
query := `
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE workspace_id = $1 OR is_shared = TRUE
ORDER BY is_shared, created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list clusters by workspace: %w", err)
}
defer rows.Close()
return r.scanClusters(rows)
}
// GetShared 获取所有共享集群
func (r *ClusterRepository) GetShared(ctx context.Context) ([]*entity.Cluster, error) {
query := `
SELECT id, workspace_id, owner_id, name, host, ca_data, cert_data, key_data, token, description, isolation_mode, default_namespace, is_shared, created_at, updated_at
FROM clusters
WHERE is_shared = TRUE
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list shared clusters: %w", err)
}
defer rows.Close()
return r.scanClusters(rows)
}
// scanClusters 扫描多行结果
func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, error) {
clusters := make([]*entity.Cluster, 0)
for rows.Next() {
cluster := &entity.Cluster{}
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
var (
encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken sql.NullString
workspaceID, ownerID, defaultNamespace sql.NullString
)
err := rows.Scan(
&cluster.ID,
&workspaceID,
&ownerID,
&cluster.Name,
&cluster.Host,
&encryptedCAData,
@ -297,6 +337,9 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
&encryptedKeyData,
&encryptedToken,
&cluster.Description,
&cluster.IsolationMode,
&defaultNamespace,
&cluster.IsShared,
&cluster.CreatedAt,
&cluster.UpdatedAt,
)
@ -304,25 +347,23 @@ func (r *ClusterRepository) List(ctx context.Context) ([]*entity.Cluster, error)
return nil, fmt.Errorf("failed to scan cluster: %w", err)
}
// 处理 NULL 值
cluster.WorkspaceID = workspaceID.String
cluster.OwnerID = ownerID.String
cluster.DefaultNamespace = defaultNamespace.String
// 解密敏感数据
cluster.CAData, err = r.encryptor.Decrypt(encryptedCAData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt CA data: %w", err)
if encryptedCAData.Valid {
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData.String)
}
cluster.CertData, err = r.encryptor.Decrypt(encryptedCertData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt cert data: %w", err)
if encryptedCertData.Valid {
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData.String)
}
cluster.KeyData, err = r.encryptor.Decrypt(encryptedKeyData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key data: %w", err)
if encryptedKeyData.Valid {
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData.String)
}
cluster.Token, err = r.encryptor.Decrypt(encryptedToken)
if err != nil {
return nil, fmt.Errorf("failed to decrypt token: %w", err)
if encryptedToken.Valid {
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken.String)
}
clusters = append(clusters, cluster)

View File

@ -124,6 +124,58 @@ func (db *DB) InitSchema() error {
CREATE INDEX IF NOT EXISTS idx_instances_cluster ON instances(cluster_id);
CREATE INDEX IF NOT EXISTS idx_instances_registry ON instances(registry_id);
CREATE INDEX IF NOT EXISTS idx_instances_name ON instances(name);
-- Storage Backends 表
CREATE TABLE IF NOT EXISTS storage_backends (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
config JSONB NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
is_shared BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id);
-- Chart References 表
CREATE TABLE IF NOT EXISTS chart_references (
id VARCHAR(36) PRIMARY KEY,
workspace_id VARCHAR(36),
registry_id VARCHAR(36),
repository VARCHAR(500) NOT NULL,
chart_name VARCHAR(255) NOT NULL,
description TEXT,
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_chart_workspace ON chart_references(workspace_id);
CREATE INDEX IF NOT EXISTS idx_chart_registry ON chart_references(registry_id);
-- Values Templates 表 - 使用复合唯一键替代主键,允许同一模板的多个版本
CREATE TABLE IF NOT EXISTS values_templates (
id VARCHAR(36),
workspace_id VARCHAR(36),
owner_id VARCHAR(36),
chart_reference_id VARCHAR(36),
name VARCHAR(255) NOT NULL,
description TEXT,
values_yaml TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
is_default BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (chart_reference_id, name, version)
);
CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id);
CREATE INDEX IF NOT EXISTS idx_values_template_workspace ON values_templates(workspace_id);
`
_, err := db.conn.Exec(schema)

View File

@ -431,3 +431,105 @@ func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, erro
return instances, nil
}
// GetByWorkspace 列出指定工作空间的所有实例(用于配额检查)
func (r *InstanceRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.Instance, error) {
query := `
SELECT id, cluster_id, workspace_id, owner_id, name, namespace, registry_id, repository, chart, version,
description, values, values_yaml, values_template_id, user_override_yaml,
status, status_reason, last_operation, last_error, revision,
cpu_requested, memory_requested, gpu_requested, gpu_memory_requested,
created_at, updated_at
FROM instances
WHERE workspace_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to get instances by workspace: %w", err)
}
defer rows.Close()
instances := make([]*entity.Instance, 0)
for rows.Next() {
instance := &entity.Instance{}
var (
valuesJSON []byte
statusReason sql.NullString
lastOperation sql.NullString
lastError sql.NullString
valuesTemplateID sql.NullString
userOverrideYAML sql.NullString
memoryRequested sql.NullString
gpuMemoryRequested sql.NullString
)
err := rows.Scan(
&instance.ID,
&instance.ClusterID,
&instance.WorkspaceID,
&instance.OwnerID,
&instance.Name,
&instance.Namespace,
&instance.RegistryID,
&instance.Repository,
&instance.Chart,
&instance.Version,
&instance.Description,
&valuesJSON,
&instance.ValuesYAML,
&valuesTemplateID,
&userOverrideYAML,
&instance.Status,
&statusReason,
&lastOperation,
&lastError,
&instance.Revision,
&instance.CPURequested,
&memoryRequested,
&instance.GPURequested,
&gpuMemoryRequested,
&instance.CreatedAt,
&instance.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan instance: %w", err)
}
if valuesJSON != nil {
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
}
}
if valuesTemplateID.Valid {
instance.ValuesTemplateID = valuesTemplateID.String
}
if userOverrideYAML.Valid {
instance.UserOverrideYAML = userOverrideYAML.String
}
if statusReason.Valid {
instance.StatusReason = statusReason.String
}
if lastOperation.Valid {
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
}
if lastError.Valid {
instance.LastError = lastError.String
}
if memoryRequested.Valid {
instance.MemoryRequested = memoryRequested.String
}
if gpuMemoryRequested.Valid {
instance.GPUMemoryRequested = gpuMemoryRequested.String
}
instances = append(instances, instance)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return instances, nil
}

View File

@ -0,0 +1,212 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// QuotaRepository PostgreSQL 配额仓储实现
type QuotaRepository struct {
db *DB
}
// NewQuotaRepository 创建 PostgreSQL 配额仓储
func NewQuotaRepository(db *DB) repository.QuotaRepository {
return &QuotaRepository{db: db}
}
// Create 创建配额
func (r *QuotaRepository) Create(ctx context.Context, quota *entity.WorkspaceQuota) error {
if quota.ID == "" {
quota.ID = uuid.New().String()
}
query := `
INSERT INTO workspace_quotas (id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_id, resource_type) DO UPDATE
SET hard_limit = $4, soft_limit = $5, updated_at = $8
`
_, err := r.db.conn.ExecContext(ctx, query,
quota.ID,
quota.WorkspaceID,
quota.ResourceType,
quota.HardLimit,
quota.SoftLimit,
quota.Used,
quota.CreatedAt,
quota.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create quota: %w", err)
}
return nil
}
// GetByID 根据 ID 获取配额
func (r *QuotaRepository) GetByID(ctx context.Context, id string) (*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE id = $1
`
quota := &entity.WorkspaceQuota{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get quota: %w", err)
}
return quota, nil
}
// GetByWorkspaceAndType 根据 workspace 和资源类型获取配额
func (r *QuotaRepository) GetByWorkspaceAndType(ctx context.Context, workspaceID string, resourceType entity.ResourceType) (*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE workspace_id = $1 AND resource_type = $2
`
quota := &entity.WorkspaceQuota{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, resourceType).Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get quota: %w", err)
}
return quota, nil
}
// GetByWorkspace 获取 workspace 的所有配额
func (r *QuotaRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.WorkspaceQuota, error) {
query := `
SELECT id, workspace_id, resource_type, hard_limit, soft_limit, used, created_at, updated_at
FROM workspace_quotas
WHERE workspace_id = $1
ORDER BY resource_type
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list quotas: %w", err)
}
defer rows.Close()
quotas := make([]*entity.WorkspaceQuota, 0)
for rows.Next() {
quota := &entity.WorkspaceQuota{}
err := rows.Scan(
&quota.ID,
&quota.WorkspaceID,
&quota.ResourceType,
&quota.HardLimit,
&quota.SoftLimit,
&quota.Used,
&quota.CreatedAt,
&quota.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan quota: %w", err)
}
quotas = append(quotas, quota)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return quotas, nil
}
// Update 更新配额
func (r *QuotaRepository) Update(ctx context.Context, quota *entity.WorkspaceQuota) error {
quota.UpdatedAt = time.Now()
query := `
UPDATE workspace_quotas
SET hard_limit = $1, soft_limit = $2, used = $3, updated_at = $4
WHERE id = $5
`
result, err := r.db.conn.ExecContext(ctx, query,
quota.HardLimit,
quota.SoftLimit,
quota.Used,
quota.UpdatedAt,
quota.ID,
)
if err != nil {
return fmt.Errorf("failed to update quota: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return fmt.Errorf("quota not found")
}
return nil
}
// Delete 删除配额
func (r *QuotaRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM workspace_quotas WHERE id = $1`
_, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete quota: %w", err)
}
return nil
}
// DeleteByWorkspace 删除 workspace 的所有配额
func (r *QuotaRepository) DeleteByWorkspace(ctx context.Context, workspaceID string) error {
query := `DELETE FROM workspace_quotas WHERE workspace_id = $1`
_, err := r.db.conn.ExecContext(ctx, query, workspaceID)
if err != nil {
return fmt.Errorf("failed to delete quotas: %w", err)
}
return nil
}

View File

@ -208,7 +208,7 @@ func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
// List 列出所有 Registries
func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, error) {
query := `
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
SELECT id, workspace_id, owner_id, name, url, description, username, password, insecure, is_shared, created_at, updated_at
FROM registries
ORDER BY created_at DESC
`
@ -222,16 +222,19 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
registries := make([]*entity.Registry, 0)
for rows.Next() {
registry := &entity.Registry{}
var encryptedPassword string
var encryptedPassword, workspaceID, ownerID sql.NullString
err := rows.Scan(
&registry.ID,
&workspaceID,
&ownerID,
&registry.Name,
&registry.URL,
&registry.Description,
&registry.Username,
&encryptedPassword,
&registry.Insecure,
&registry.IsShared,
&registry.CreatedAt,
&registry.UpdatedAt,
)
@ -239,10 +242,13 @@ func (r *RegistryRepository) List(ctx context.Context) ([]*entity.Registry, erro
return nil, fmt.Errorf("failed to scan registry: %w", err)
}
// 处理 NULL 值
registry.WorkspaceID = workspaceID.String
registry.OwnerID = ownerID.String
// 解密密码
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
if encryptedPassword.Valid {
registry.Password, _ = r.encryptor.Decrypt(encryptedPassword.String)
}
registries = append(registries, registry)

View File

@ -0,0 +1,326 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// StorageRepository PostgreSQL 存储后端仓储实现
type StorageRepository struct {
db *DB
}
// NewStorageRepository 创建 PostgreSQL 存储后端仓储
func NewStorageRepository(db *DB) repository.StorageRepository {
return &StorageRepository{db: db}
}
// Create 创建存储后端
func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageBackend) error {
if storage.ID == "" {
storage.ID = uuid.New().String()
}
configJSON, err := json.Marshal(storage.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
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)
`
_, err = r.db.conn.ExecContext(ctx, query,
storage.ID,
storage.WorkspaceID,
storage.OwnerID,
storage.Name,
storage.Type,
configJSON,
storage.Description,
storage.IsDefault,
storage.IsShared,
storage.CreatedAt,
storage.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create storage: %w", err)
}
return nil
}
// 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
FROM storage_backends
WHERE id = $1
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// 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
FROM storage_backends
WHERE workspace_id = $1 AND name = $2
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrStorageNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return storage, nil
}
// 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
FROM storage_backends
WHERE workspace_id = $1 OR is_shared = TRUE
ORDER BY is_default DESC, name
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// 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
FROM storage_backends
WHERE is_shared = TRUE
ORDER BY name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list shared storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// 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
FROM storage_backends
WHERE workspace_id = $1 AND is_default = TRUE
LIMIT 1
`
storage := &entity.StorageBackend{}
var configJSON []byte
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get default storage: %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()
configJSON, err := json.Marshal(storage.Config)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
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
`
result, err := r.db.conn.ExecContext(ctx, query,
storage.Name,
storage.Type,
configJSON,
storage.Description,
storage.IsDefault,
storage.IsShared,
storage.UpdatedAt,
storage.ID,
)
if err != nil {
return fmt.Errorf("failed to update storage: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrStorageNotFound
}
return nil
}
// Delete 删除存储后端
func (r *StorageRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM storage_backends WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete storage: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrStorageNotFound
}
return nil
}
// 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
FROM storage_backends
ORDER BY workspace_id, name
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list storage: %w", err)
}
defer rows.Close()
return r.scanStorages(rows)
}
// scanStorages 扫描多行结果
func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBackend, error) {
storages := make([]*entity.StorageBackend, 0)
for rows.Next() {
storage := &entity.StorageBackend{}
var configJSON []byte
err := rows.Scan(
&storage.ID,
&storage.WorkspaceID,
&storage.OwnerID,
&storage.Name,
&storage.Type,
&configJSON,
&storage.Description,
&storage.IsDefault,
&storage.IsShared,
&storage.CreatedAt,
&storage.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan storage: %w", err)
}
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
storages = append(storages, storage)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return storages, nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"fmt"
"log"
"time"
"github.com/google/uuid"
@ -27,9 +28,14 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.ID = uuid.New().String()
}
// 设置默认值
if user.IsActive {
user.IsActive = true
}
query := `
INSERT INTO users (id, username, password_hash, email, revoked_after, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
INSERT INTO users (id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err := r.db.conn.ExecContext(ctx, query,
@ -37,6 +43,10 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
user.Username,
user.PasswordHash,
user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter,
user.CreatedAt,
user.UpdatedAt,
@ -52,22 +62,34 @@ func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
// GetByID 根据 ID 获取用户
func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User, error) {
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE id = $1
`
user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
// Handle NULL workspace_id
if workspaceID.Valid {
user.WorkspaceID = workspaceID.String
} else {
user.WorkspaceID = ""
}
if err == sql.ErrNoRows {
return nil, entity.ErrUserNotFound
}
@ -80,30 +102,50 @@ func (r *UserRepository) GetByID(ctx context.Context, id string) (*entity.User,
// GetByUsername 根据用户名获取用户
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
log.Printf("[DEBUG] GetByUsername called with username: %q", username)
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE username = $1
`
log.Printf("[DEBUG] Executing query: %s with param: %s", query, username)
user := &entity.User{}
var workspaceID sql.NullString
err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&workspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
// Handle NULL workspace_id
if workspaceID.Valid {
user.WorkspaceID = workspaceID.String
} else {
user.WorkspaceID = ""
}
log.Printf("[DEBUG] Query result - err: %v", err)
if err == sql.ErrNoRows {
log.Printf("[DEBUG] User not found in DB")
return nil, entity.ErrUserNotFound
}
if err != nil {
log.Printf("[DEBUG] Scan error: %v", err)
return nil, fmt.Errorf("failed to get user: %w", err)
}
log.Printf("[DEBUG] Found user: %+v", user)
return user, nil
}
@ -113,14 +155,18 @@ func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
query := `
UPDATE users
SET username = $1, password_hash = $2, email = $3, revoked_after = $4, updated_at = $5
WHERE id = $6
SET username = $1, password_hash = $2, email = $3, role = $4, workspace_id = $5, is_active = $6, must_change_password = $7, revoked_after = $8, updated_at = $9
WHERE id = $10
`
result, err := r.db.conn.ExecContext(ctx, query,
user.Username,
user.PasswordHash,
user.Email,
user.Role,
user.WorkspaceID,
user.IsActive,
user.MustChangePassword,
user.RevokedAfter,
user.UpdatedAt,
user.ID,
@ -166,7 +212,7 @@ func (r *UserRepository) Delete(ctx context.Context, id string) error {
// List 列出所有用户
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
ORDER BY created_at DESC
`
@ -185,6 +231,98 @@ func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return users, nil
}
// ListByWorkspace 列出指定 workspace 的用户
func (r *UserRepository) ListByWorkspace(ctx context.Context, workspaceID string) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE workspace_id = $1
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list users by workspace: %w", err)
}
defer rows.Close()
users := make([]*entity.User, 0)
for rows.Next() {
user := &entity.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan user: %w", err)
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return users, nil
}
// ListActive 仅列出活跃用户
func (r *UserRepository) ListActive(ctx context.Context) ([]*entity.User, error) {
query := `
SELECT id, username, password_hash, email, role, workspace_id, is_active, must_change_password, revoked_after, created_at, updated_at
FROM users
WHERE is_active = TRUE
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list active users: %w", err)
}
defer rows.Close()
users := make([]*entity.User, 0)
for rows.Next() {
user := &entity.User{}
err := rows.Scan(
&user.ID,
&user.Username,
&user.PasswordHash,
&user.Email,
&user.Role,
&user.WorkspaceID,
&user.IsActive,
&user.MustChangePassword,
&user.RevokedAfter,
&user.CreatedAt,
&user.UpdatedAt,

View File

@ -0,0 +1,287 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// ValuesTemplateRepository PostgreSQL Values 模板仓储实现
type ValuesTemplateRepository struct {
db *DB
}
// NewValuesTemplateRepository 创建 PostgreSQL Values 模板仓储
func NewValuesTemplateRepository(db *DB) repository.ValuesTemplateRepository {
return &ValuesTemplateRepository{db: db}
}
// Create 创建 Values 模板
func (r *ValuesTemplateRepository) Create(ctx context.Context, template *entity.ValuesTemplate) error {
if template.ID == "" {
template.ID = uuid.New().String()
}
query := `
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err := r.db.conn.ExecContext(ctx, query,
template.ID,
template.WorkspaceID,
template.OwnerID,
template.ChartReferenceID,
template.Name,
template.Description,
template.ValuesYAML,
template.Version,
template.IsDefault,
template.CreatedAt,
template.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create values template: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Values 模板
func (r *ValuesTemplateRepository) GetByID(ctx context.Context, id string) (*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE id = $1
`
template := &entity.ValuesTemplate{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get values template: %w", err)
}
return template, nil
}
// GetByWorkspace 获取 workspace 的所有 Values 模板
func (r *ValuesTemplateRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE workspace_id = $1
ORDER BY chart_reference_id, name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, workspaceID)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// GetByChartReference 获取 Chart Reference 的所有 Values 模板
func (r *ValuesTemplateRepository) GetByChartReference(ctx context.Context, chartRefID string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE chart_reference_id = $1
ORDER BY name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// GetByName 根据名称获取 Values 模板(获取最新版本)
func (r *ValuesTemplateRepository) GetByName(ctx context.Context, workspaceID, chartRefID, name string) (*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE workspace_id = $1 AND chart_reference_id = $2 AND name = $3
ORDER BY version DESC
LIMIT 1
`
template := &entity.ValuesTemplate{}
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, chartRefID, name).Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrTemplateNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get values template: %w", err)
}
return template, nil
}
// GetHistory 获取模板的版本历史
func (r *ValuesTemplateRepository) GetHistory(ctx context.Context, chartRefID, name string) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
WHERE chart_reference_id = $1 AND name = $2
ORDER BY version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query, chartRefID, name)
if err != nil {
return nil, fmt.Errorf("failed to get values template history: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// Update 更新 Values 模板(自动递增版本)
func (r *ValuesTemplateRepository) Update(ctx context.Context, template *entity.ValuesTemplate) error {
// 获取当前最大版本号
var maxVersion int
err := r.db.conn.QueryRowContext(ctx,
"SELECT COALESCE(MAX(version), 0) FROM values_templates WHERE chart_reference_id = $1 AND name = $2",
template.ChartReferenceID, template.Name,
).Scan(&maxVersion)
if err != nil {
return fmt.Errorf("failed to get max version: %w", err)
}
// 生成新 ID 用于新版本
newID := uuid.New().String()
template.Version = maxVersion + 1
template.UpdatedAt = time.Now()
template.CreatedAt = time.Now() // 新版本的创建时间
query := `
INSERT INTO values_templates (id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
_, err = r.db.conn.ExecContext(ctx, query,
newID,
template.WorkspaceID,
template.OwnerID,
template.ChartReferenceID,
template.Name,
template.Description,
template.ValuesYAML,
template.Version,
template.IsDefault,
template.CreatedAt,
template.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to update values template: %w", err)
}
return nil
}
// Delete 删除 Values 模板
func (r *ValuesTemplateRepository) Delete(ctx context.Context, id string) error {
// 获取模板信息
template, err := r.GetByID(ctx, id)
if err != nil {
return err
}
// 删除该名称的所有版本
query := `DELETE FROM values_templates WHERE chart_reference_id = $1 AND name = $2`
_, err = r.db.conn.ExecContext(ctx, query, template.ChartReferenceID, template.Name)
if err != nil {
return fmt.Errorf("failed to delete values template: %w", err)
}
return nil
}
// List 列出所有 Values 模板(管理员用)
func (r *ValuesTemplateRepository) List(ctx context.Context) ([]*entity.ValuesTemplate, error) {
query := `
SELECT id, workspace_id, owner_id, chart_reference_id, name, description, values_yaml, version, is_default, created_at, updated_at
FROM values_templates
ORDER BY workspace_id, chart_reference_id, name, version DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list values templates: %w", err)
}
defer rows.Close()
return r.scanValuesTemplates(rows)
}
// scanValuesTemplates 扫描多行结果
func (r *ValuesTemplateRepository) scanValuesTemplates(rows *sql.Rows) ([]*entity.ValuesTemplate, error) {
templates := make([]*entity.ValuesTemplate, 0)
for rows.Next() {
template := &entity.ValuesTemplate{}
err := rows.Scan(
&template.ID,
&template.WorkspaceID,
&template.OwnerID,
&template.ChartReferenceID,
&template.Name,
&template.Description,
&template.ValuesYAML,
&template.Version,
&template.IsDefault,
&template.CreatedAt,
&template.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan values template: %w", err)
}
templates = append(templates, template)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return templates, nil
}

View File

@ -0,0 +1,197 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/repository"
)
// WorkspaceRepository PostgreSQL Workspace 仓储实现
type WorkspaceRepository struct {
db *DB
}
// NewWorkspaceRepository 创建 PostgreSQL Workspace 仓储
func NewWorkspaceRepository(db *DB) repository.WorkspaceRepository {
return &WorkspaceRepository{db: db}
}
// Create 创建 Workspace
func (r *WorkspaceRepository) Create(ctx context.Context, workspace *entity.Workspace) error {
if workspace.ID == "" {
workspace.ID = uuid.New().String()
}
query := `
INSERT INTO workspaces (id, name, description, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
`
_, err := r.db.conn.ExecContext(ctx, query,
workspace.ID,
workspace.Name,
workspace.Description,
workspace.CreatedBy,
workspace.CreatedAt,
workspace.UpdatedAt,
)
if err != nil {
return fmt.Errorf("failed to create workspace: %w", err)
}
return nil
}
// GetByID 根据 ID 获取 Workspace
func (r *WorkspaceRepository) GetByID(ctx context.Context, id string) (*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
WHERE id = $1
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
return workspace, nil
}
// GetByName 根据名称获取 Workspace
func (r *WorkspaceRepository) GetByName(ctx context.Context, name string) (*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
WHERE name = $1
`
workspace := &entity.Workspace{}
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, entity.ErrWorkspaceNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to get workspace: %w", err)
}
return workspace, nil
}
// Update 更新 Workspace
func (r *WorkspaceRepository) Update(ctx context.Context, workspace *entity.Workspace) error {
workspace.UpdatedAt = time.Now()
query := `
UPDATE workspaces
SET name = $1, description = $2, updated_at = $3
WHERE id = $4
`
result, err := r.db.conn.ExecContext(ctx, query,
workspace.Name,
workspace.Description,
workspace.UpdatedAt,
workspace.ID,
)
if err != nil {
return fmt.Errorf("failed to update workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// Delete 删除 Workspace
func (r *WorkspaceRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM workspaces WHERE id = $1`
result, err := r.db.conn.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete workspace: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
return entity.ErrWorkspaceNotFound
}
return nil
}
// List 列出所有 Workspace
func (r *WorkspaceRepository) List(ctx context.Context) ([]*entity.Workspace, error) {
query := `
SELECT id, name, description, created_by, created_at, updated_at
FROM workspaces
ORDER BY created_at DESC
`
rows, err := r.db.conn.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("failed to list workspaces: %w", err)
}
defer rows.Close()
workspaces := make([]*entity.Workspace, 0)
for rows.Next() {
workspace := &entity.Workspace{}
err := rows.Scan(
&workspace.ID,
&workspace.Name,
&workspace.Description,
&workspace.CreatedBy,
&workspace.CreatedAt,
&workspace.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan workspace: %w", err)
}
workspaces = append(workspaces, workspace)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration error: %w", err)
}
return workspaces, nil
}