ocdp v1
This commit is contained in:
44
backend/internal/adapter/input/http/dto/artifact_dto.go
Normal file
44
backend/internal/adapter/input/http/dto/artifact_dto.go
Normal file
@ -0,0 +1,44 @@
|
||||
package dto
|
||||
|
||||
// RepositoryListResponse Repository 列表响应
|
||||
type RepositoryListResponse struct {
|
||||
RegistryID string `json:"registryId"`
|
||||
RegistryURL string `json:"registryUrl"`
|
||||
Repositories []string `json:"repositories"`
|
||||
Total int `json:"total"`
|
||||
CatalogSupported bool `json:"catalogSupported"` // Whether _catalog API is supported
|
||||
Source string `json:"source"` // Data source: "catalog" | "preconfigured" | "unavailable"
|
||||
Message string `json:"message,omitempty"` // User-friendly message
|
||||
}
|
||||
|
||||
// ArtifactResponse Artifact 响应(简化版本,只包含核心字段)
|
||||
type ArtifactResponse struct {
|
||||
RepositoryName string `json:"repositoryName"`
|
||||
Tag string `json:"tag"`
|
||||
Digest string `json:"digest"`
|
||||
Type string `json:"type"` // chart | image | other
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
// TagResponse Tag 响应(前端期望的扁平化结构)
|
||||
type TagResponse struct {
|
||||
RepositoryName string `json:"repositoryName"` // Repository name
|
||||
Tag string `json:"tag"` // Tag name (e.g. "1.0.0", "latest")
|
||||
Type string `json:"type"` // Artifact type: chart, image, other
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
Size int64 `json:"size"` // Artifact size (bytes)
|
||||
}
|
||||
|
||||
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
|
||||
type ArtifactListResponse struct {
|
||||
RepositoryName string `json:"repositoryName"`
|
||||
Artifacts []*ArtifactResponse `json:"artifacts"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// ValuesSchemaResponse Values Schema 响应
|
||||
type ValuesSchemaResponse struct {
|
||||
Schema string `json:"schema"`
|
||||
}
|
||||
|
||||
35
backend/internal/adapter/input/http/dto/auth_dto.go
Normal file
35
backend/internal/adapter/input/http/dto/auth_dto.go
Normal file
@ -0,0 +1,35 @@
|
||||
package dto
|
||||
|
||||
// RegisterRequest 用户注册请求
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// LoginRequest 用户登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest 刷新 Token 请求
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refreshToken" binding:"required"`
|
||||
}
|
||||
|
||||
// AuthResponse 认证响应
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// UserResponse 用户信息响应
|
||||
type UserResponse struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
82
backend/internal/adapter/input/http/dto/cluster_dto.go
Normal file
82
backend/internal/adapter/input/http/dto/cluster_dto.go
Normal file
@ -0,0 +1,82 @@
|
||||
package dto
|
||||
|
||||
// CreateClusterRequest 创建集群请求
|
||||
type CreateClusterRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Host string `json:"host" binding:"required"`
|
||||
CAData string `json:"caData"`
|
||||
CADataAlt string `json:"ca_data"`
|
||||
CertData string `json:"certData"`
|
||||
CertDataAlt string `json:"cert_data"`
|
||||
KeyData string `json:"keyData"`
|
||||
KeyDataAlt string `json:"key_data"`
|
||||
Token string `json:"token"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UpdateClusterRequest 更新集群请求
|
||||
type UpdateClusterRequest struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
CAData string `json:"caData"`
|
||||
CADataAlt string `json:"ca_data"`
|
||||
CertData string `json:"certData"`
|
||||
CertDataAlt string `json:"cert_data"`
|
||||
KeyData string `json:"keyData"`
|
||||
KeyDataAlt string `json:"key_data"`
|
||||
Token string `json:"token"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
func (r *CreateClusterRequest) Normalize() {
|
||||
if r.CAData == "" {
|
||||
r.CAData = r.CADataAlt
|
||||
}
|
||||
if r.CertData == "" {
|
||||
r.CertData = r.CertDataAlt
|
||||
}
|
||||
if r.KeyData == "" {
|
||||
r.KeyData = r.KeyDataAlt
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
func (r *UpdateClusterRequest) Normalize() {
|
||||
if r.CAData == "" {
|
||||
r.CAData = r.CADataAlt
|
||||
}
|
||||
if r.CertData == "" {
|
||||
r.CertData = r.CertDataAlt
|
||||
}
|
||||
if r.KeyData == "" {
|
||||
r.KeyData = r.KeyDataAlt
|
||||
}
|
||||
}
|
||||
|
||||
// ClusterResponse 集群响应(敏感数据已脱敏)
|
||||
type ClusterResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
|
||||
HasCAData bool `json:"hasCaData"`
|
||||
HasCertData bool `json:"hasCertData"`
|
||||
HasKeyData bool `json:"hasKeyData"`
|
||||
HasToken bool `json:"hasToken"`
|
||||
// 脱敏数据(仅用于前端显示,实际值为掩码)
|
||||
CAData string `json:"caData,omitempty"` // 脱敏显示(••••••••)
|
||||
CertData string `json:"certData,omitempty"` // 脱敏显示(••••••••)
|
||||
KeyData string `json:"keyData,omitempty"` // 脱敏显示(••••••••)
|
||||
Token string `json:"token,omitempty"` // 脱敏显示(••••••••)
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ClusterHealthResponse 集群健康状态响应
|
||||
type ClusterHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
63
backend/internal/adapter/input/http/dto/converter.go
Normal file
63
backend/internal/adapter/input/http/dto/converter.go
Normal file
@ -0,0 +1,63 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// ToRegistryResponse 转换 Registry 实体为响应 DTO(脱敏)
|
||||
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
|
||||
response := &RegistryResponse{
|
||||
ID: registry.ID,
|
||||
Name: registry.Name,
|
||||
URL: registry.URL,
|
||||
Description: registry.Description,
|
||||
Username: registry.Username,
|
||||
Insecure: registry.Insecure,
|
||||
CreatedAt: registry.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: registry.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
// 脱敏处理密码
|
||||
if registry.Password != "" {
|
||||
response.HasPassword = true
|
||||
response.Password = crypto.MaskSensitiveData(registry.Password)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// ToClusterResponse 转换 Cluster 实体为响应 DTO(脱敏)
|
||||
func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
|
||||
response := &ClusterResponse{
|
||||
ID: cluster.ID,
|
||||
Name: cluster.Name,
|
||||
Host: cluster.Host,
|
||||
Description: cluster.Description,
|
||||
CreatedAt: cluster.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: cluster.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
// 设置认证配置状态标志
|
||||
response.HasCAData = cluster.CAData != ""
|
||||
response.HasCertData = cluster.CertData != ""
|
||||
response.HasKeyData = cluster.KeyData != ""
|
||||
response.HasToken = cluster.Token != ""
|
||||
|
||||
// 脱敏处理敏感数据(仅显示掩码)
|
||||
if cluster.CAData != "" {
|
||||
response.CAData = crypto.MaskSensitiveData(cluster.CAData)
|
||||
}
|
||||
if cluster.CertData != "" {
|
||||
response.CertData = crypto.MaskSensitiveData(cluster.CertData)
|
||||
}
|
||||
if cluster.KeyData != "" {
|
||||
response.KeyData = crypto.MaskSensitiveData(cluster.KeyData)
|
||||
}
|
||||
if cluster.Token != "" {
|
||||
response.Token = crypto.MaskSensitiveData(cluster.Token)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
15
backend/internal/adapter/input/http/dto/error_dto.go
Normal file
15
backend/internal/adapter/input/http/dto/error_dto.go
Normal file
@ -0,0 +1,15 @@
|
||||
package dto
|
||||
|
||||
// ErrorResponse 错误响应
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
}
|
||||
|
||||
// SuccessResponse 成功响应
|
||||
type SuccessResponse struct {
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
133
backend/internal/adapter/input/http/dto/instance_dto.go
Normal file
133
backend/internal/adapter/input/http/dto/instance_dto.go
Normal file
@ -0,0 +1,133 @@
|
||||
package dto
|
||||
|
||||
// CreateInstanceRequest 创建实例请求
|
||||
type CreateInstanceRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Namespace string `json:"namespace" binding:"required"`
|
||||
RegistryID string `json:"registryId" binding:"required"`
|
||||
RegistryIDAlt string `json:"registry_id"`
|
||||
Repository string `json:"repository" binding:"required"`
|
||||
Tag string `json:"tag" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
}
|
||||
|
||||
// UpdateInstanceRequest 更新实例请求
|
||||
type UpdateInstanceRequest struct {
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Values map[string]interface{} `json:"values"`
|
||||
ValuesYAML string `json:"valuesYaml"`
|
||||
}
|
||||
|
||||
// Normalize 将多种命名风格的字段合并到统一字段
|
||||
func (r *CreateInstanceRequest) Normalize() {
|
||||
if r.RegistryID == "" {
|
||||
r.RegistryID = r.RegistryIDAlt
|
||||
}
|
||||
}
|
||||
|
||||
// RollbackInstanceRequest 回滚实例请求
|
||||
type RollbackInstanceRequest struct {
|
||||
Revision int `json:"revision" binding:"required"`
|
||||
Wait bool `json:"wait"`
|
||||
Timeout int `json:"timeout"` // seconds
|
||||
}
|
||||
|
||||
// DeleteInstanceRequest 删除实例请求
|
||||
type DeleteInstanceRequest struct {
|
||||
KeepHistory bool `json:"keepHistory"`
|
||||
Timeout int `json:"timeout"` // seconds
|
||||
}
|
||||
|
||||
// InstanceResponse 实例响应
|
||||
type InstanceResponse struct {
|
||||
ID string `json:"id"`
|
||||
ClusterID string `json:"clusterId"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
RegistryID string `json:"registryId"`
|
||||
Repository string `json:"repository"`
|
||||
Chart string `json:"chart"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
StatusReason string `json:"statusReason,omitempty"`
|
||||
LastOperation string `json:"lastOperation,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Revision int `json:"revision"`
|
||||
Values map[string]interface{} `json:"values,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// InstanceStatusResponse 实例状态响应
|
||||
type InstanceStatusResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Status string `json:"status"`
|
||||
Revision int `json:"revision"`
|
||||
Chart string `json:"chart"`
|
||||
Version string `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ReleaseHistoryResponse Release 历史响应
|
||||
type ReleaseHistoryResponse struct {
|
||||
Revision int `json:"revision"`
|
||||
Updated string `json:"updated"`
|
||||
Status string `json:"status"`
|
||||
Chart string `json:"chart"`
|
||||
AppVersion string `json:"appVersion"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// InstanceListResponse 实例列表响应
|
||||
type InstanceListResponse struct {
|
||||
Instances []*InstanceResponse `json:"instances"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// InstanceEntryPortResponse Service 端口响应
|
||||
type InstanceEntryPortResponse struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port int32 `json:"port"`
|
||||
TargetPort string `json:"targetPort,omitempty"`
|
||||
NodePort int32 `json:"nodePort,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceEntryPathResponse Ingress path 响应
|
||||
type InstanceEntryPathResponse struct {
|
||||
Path string `json:"path"`
|
||||
ServiceName string `json:"serviceName,omitempty"`
|
||||
ServicePort string `json:"servicePort,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceEntryHostResponse Ingress host 响应
|
||||
type InstanceEntryHostResponse struct {
|
||||
Host string `json:"host"`
|
||||
Paths []InstanceEntryPathResponse `json:"paths,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceEntryTLSResponse Ingress TLS 响应
|
||||
type InstanceEntryTLSResponse struct {
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
SecretName string `json:"secretName,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceEntryResponse 实例入口响应
|
||||
type InstanceEntryResponse struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
Type string `json:"type,omitempty"`
|
||||
ClusterIP string `json:"clusterIP,omitempty"`
|
||||
ExternalIPs []string `json:"externalIPs,omitempty"`
|
||||
LoadBalancerIngress []string `json:"loadBalancerIngress,omitempty"`
|
||||
Ports []InstanceEntryPortResponse `json:"ports,omitempty"`
|
||||
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
|
||||
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
|
||||
}
|
||||
143
backend/internal/adapter/input/http/dto/monitoring_dto.go
Normal file
143
backend/internal/adapter/input/http/dto/monitoring_dto.go
Normal file
@ -0,0 +1,143 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// ClusterMetricsResponse 集群监控响应
|
||||
type ClusterMetricsResponse struct {
|
||||
ClusterID string `json:"clusterId"`
|
||||
ClusterName string `json:"clusterName"`
|
||||
Status string `json:"status"`
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
PodCount int `json:"podCount"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
TotalCPU string `json:"totalCpu"`
|
||||
TotalMemory string `json:"totalMemory"`
|
||||
TotalGPU int `json:"totalGpu"`
|
||||
UsedCPU string `json:"usedCpu"`
|
||||
UsedMemory string `json:"usedMemory"`
|
||||
UsedGPU int `json:"usedGpu"`
|
||||
CPUUsage float64 `json:"cpuUsage"`
|
||||
MemoryUsage float64 `json:"memoryUsage"`
|
||||
GPUUsage float64 `json:"gpuUsage"`
|
||||
MaxNodeCPU string `json:"maxNodeCpu"`
|
||||
MaxNodeMemory string `json:"maxNodeMemory"`
|
||||
MaxNodeGPU int `json:"maxNodeGpu"`
|
||||
MaxNodeCPUUsage float64 `json:"maxNodeCpuUsage"`
|
||||
MaxNodeMemUsage float64 `json:"maxNodeMemUsage"`
|
||||
MaxNodeGPUUsage float64 `json:"maxNodeGpuUsage"`
|
||||
Nodes []NodeMetricsResponse `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMetricsResponse 节点监控响应
|
||||
type NodeMetricsResponse struct {
|
||||
NodeName string `json:"nodeName"`
|
||||
Status string `json:"status"`
|
||||
Role string `json:"role"`
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"podCount"`
|
||||
CPUCapacity string `json:"cpuCapacity"`
|
||||
CPUAllocatable string `json:"cpuAllocatable"`
|
||||
CPUUsage string `json:"cpuUsage"`
|
||||
CPUPercent float64 `json:"cpuPercent"`
|
||||
MemoryCapacity string `json:"memoryCapacity"`
|
||||
MemoryAllocatable string `json:"memoryAllocatable"`
|
||||
MemoryUsage string `json:"memoryUsage"`
|
||||
MemoryPercent float64 `json:"memoryPercent"`
|
||||
GPUCapacity int `json:"gpuCapacity"`
|
||||
GPUUsage int `json:"gpuUsage"`
|
||||
GPUPercent float64 `json:"gpuPercent"`
|
||||
GPUType string `json:"gpuType,omitempty"`
|
||||
OSImage string `json:"osImage,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
ContainerRuntime string `json:"containerRuntime,omitempty"`
|
||||
KubeletVersion string `json:"kubeletVersion,omitempty"`
|
||||
}
|
||||
|
||||
// MonitoringSummaryResponse 监控汇总响应
|
||||
type MonitoringSummaryResponse struct {
|
||||
TotalClusters int `json:"totalClusters"`
|
||||
HealthyClusters int `json:"healthyClusters"`
|
||||
WarningClusters int `json:"warningClusters"`
|
||||
ErrorClusters int `json:"errorClusters"`
|
||||
TotalNodes int `json:"totalNodes"`
|
||||
TotalPods int `json:"totalPods"`
|
||||
LastUpdate time.Time `json:"lastUpdate"`
|
||||
}
|
||||
|
||||
// ToClusterMetricsResponse 转换为响应
|
||||
func ToClusterMetricsResponse(m *entity.ClusterMetrics) *ClusterMetricsResponse {
|
||||
resp := &ClusterMetricsResponse{
|
||||
ClusterID: m.ClusterID,
|
||||
ClusterName: m.ClusterName,
|
||||
Status: m.Status,
|
||||
Uptime: m.Uptime,
|
||||
NodeCount: m.NodeCount,
|
||||
PodCount: m.PodCount,
|
||||
LastCheck: m.LastCheck,
|
||||
TotalCPU: m.TotalCPU,
|
||||
TotalMemory: m.TotalMemory,
|
||||
TotalGPU: m.TotalGPU,
|
||||
UsedCPU: m.UsedCPU,
|
||||
UsedMemory: m.UsedMemory,
|
||||
UsedGPU: m.UsedGPU,
|
||||
CPUUsage: m.CPUUsage,
|
||||
MemoryUsage: m.MemoryUsage,
|
||||
GPUUsage: m.GPUUsage,
|
||||
MaxNodeCPU: m.MaxNodeCPU,
|
||||
MaxNodeMemory: m.MaxNodeMemory,
|
||||
MaxNodeGPU: m.MaxNodeGPU,
|
||||
MaxNodeCPUUsage: m.MaxNodeCPUUsage,
|
||||
MaxNodeMemUsage: m.MaxNodeMemUsage,
|
||||
MaxNodeGPUUsage: m.MaxNodeGPUUsage,
|
||||
}
|
||||
|
||||
if len(m.Nodes) > 0 {
|
||||
resp.Nodes = make([]NodeMetricsResponse, len(m.Nodes))
|
||||
for i, node := range m.Nodes {
|
||||
resp.Nodes[i] = NodeMetricsResponse{
|
||||
NodeName: node.NodeName,
|
||||
Status: node.Status,
|
||||
Role: node.Role,
|
||||
Age: node.Age,
|
||||
PodCount: node.PodCount,
|
||||
CPUCapacity: node.CPUCapacity,
|
||||
CPUAllocatable: node.CPUAllocatable,
|
||||
CPUUsage: node.CPUUsage,
|
||||
CPUPercent: node.CPUPercent,
|
||||
MemoryCapacity: node.MemoryCapacity,
|
||||
MemoryAllocatable: node.MemoryAllocatable,
|
||||
MemoryUsage: node.MemoryUsage,
|
||||
MemoryPercent: node.MemoryPercent,
|
||||
GPUCapacity: node.GPUCapacity,
|
||||
GPUUsage: node.GPUUsage,
|
||||
GPUPercent: node.GPUPercent,
|
||||
GPUType: node.GPUType,
|
||||
OSImage: node.OSImage,
|
||||
KernelVersion: node.KernelVersion,
|
||||
ContainerRuntime: node.ContainerRuntime,
|
||||
KubeletVersion: node.KubeletVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToMonitoringSummaryResponse 转换为汇总响应
|
||||
func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummaryResponse {
|
||||
return &MonitoringSummaryResponse{
|
||||
TotalClusters: s.TotalClusters,
|
||||
HealthyClusters: s.HealthyClusters,
|
||||
WarningClusters: s.WarningClusters,
|
||||
ErrorClusters: s.ErrorClusters,
|
||||
TotalNodes: s.TotalNodes,
|
||||
TotalPods: s.TotalPods,
|
||||
LastUpdate: s.LastUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
42
backend/internal/adapter/input/http/dto/registry_dto.go
Normal file
42
backend/internal/adapter/input/http/dto/registry_dto.go
Normal file
@ -0,0 +1,42 @@
|
||||
package dto
|
||||
|
||||
// CreateRegistryRequest 创建 Registry 请求
|
||||
type CreateRegistryRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
}
|
||||
|
||||
// UpdateRegistryRequest 更新 Registry 请求
|
||||
type UpdateRegistryRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Description string `json:"description"`
|
||||
Insecure bool `json:"insecure"`
|
||||
}
|
||||
|
||||
// RegistryResponse Registry 响应(敏感数据已脱敏)
|
||||
type RegistryResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Username string `json:"username,omitempty"` // 明文返回用户名(不敏感)
|
||||
Password string `json:"password,omitempty"` // 脱敏显示(••••••••)
|
||||
HasPassword bool `json:"hasPassword"` // 是否已设置密码
|
||||
Insecure bool `json:"insecure"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RegistryHealthResponse Registry 健康状态响应
|
||||
type RegistryHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
193
backend/internal/adapter/input/http/rest/artifact_handler.go
Normal file
193
backend/internal/adapter/input/http/rest/artifact_handler.go
Normal file
@ -0,0 +1,193 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// ArtifactHandler Artifact Handler
|
||||
type ArtifactHandler struct {
|
||||
artifactService *service.ArtifactService
|
||||
}
|
||||
|
||||
// NewArtifactHandler 创建 Artifact Handler
|
||||
func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandler {
|
||||
return &ArtifactHandler{
|
||||
artifactService: artifactService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
// @Summary 列出 Registry 中的所有 Repositories
|
||||
// @Description 列出指定 Registry 中的所有 Repository
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Success 200 {object} dto.RepositoryListResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories [get]
|
||||
func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Get registry info for URL
|
||||
registry, err := h.artifactService.GetRegistry(r.Context(), registryID)
|
||||
registryURL := ""
|
||||
if err == nil && registry != nil {
|
||||
registryURL = registry.URL
|
||||
}
|
||||
|
||||
// Determine source and message based on repository count
|
||||
source := "catalog"
|
||||
catalogSupported := true
|
||||
message := ""
|
||||
|
||||
if len(repositories) == 0 {
|
||||
source = "unavailable"
|
||||
message = "No repositories found in this registry"
|
||||
}
|
||||
|
||||
response := &dto.RepositoryListResponse{
|
||||
RegistryID: registryID,
|
||||
RegistryURL: registryURL,
|
||||
Repositories: repositories,
|
||||
Total: len(repositories),
|
||||
CatalogSupported: catalogSupported,
|
||||
Source: source,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ListArtifacts 列出 repository 中的所有 artifacts(返回扁平化的 Tag 数组)
|
||||
// @Summary 列出 Repository 中的所有 Artifacts
|
||||
// @Description 列出指定 Repository 中的所有 Artifact,支持按类型过滤
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded, e.g. charts%2Fnginx)"
|
||||
// @Param media_type query string false "过滤 Artifact 类型 (all, chart, image, other)" default(all)
|
||||
// @Success 200 {array} dto.TagResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts [get]
|
||||
func (h *ArtifactHandler) ListArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
repositoryName := vars["repository_name"]
|
||||
|
||||
// 获取 mediaType 过滤参数(默认为 "all")
|
||||
mediaTypeFilter := r.URL.Query().Get("media_type")
|
||||
if mediaTypeFilter == "" {
|
||||
mediaTypeFilter = "all"
|
||||
}
|
||||
|
||||
artifacts, err := h.artifactService.ListArtifacts(r.Context(), registryID, repositoryName, mediaTypeFilter)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list artifacts", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为前端期望的扁平化 Tag 数组
|
||||
tagResponses := make([]*dto.TagResponse, 0, len(artifacts))
|
||||
for _, artifact := range artifacts {
|
||||
tagResponses = append(tagResponses, &dto.TagResponse{
|
||||
RepositoryName: artifact.Repository,
|
||||
Tag: artifact.Tag,
|
||||
Type: string(artifact.Type),
|
||||
MediaType: artifact.MediaType,
|
||||
Size: artifact.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// 直接返回数组,不包装
|
||||
respondJSON(w, http.StatusOK, tagResponses)
|
||||
}
|
||||
|
||||
// GetArtifact 获取 artifact 详情
|
||||
// @Summary 获取 Artifact 详情
|
||||
// @Description 获取指定 Artifact 的详细信息
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||
// @Success 200 {object} dto.ArtifactResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference} [get]
|
||||
func (h *ArtifactHandler) GetArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
repositoryName := vars["repository_name"]
|
||||
reference := vars["reference"]
|
||||
|
||||
artifact, err := h.artifactService.GetArtifact(r.Context(), registryID, repositoryName, reference)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Artifact not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.ArtifactResponse{
|
||||
RepositoryName: artifact.Repository,
|
||||
Tag: artifact.Tag,
|
||||
Digest: artifact.Digest,
|
||||
Type: string(artifact.Type),
|
||||
Size: artifact.Size,
|
||||
CreatedAt: artifact.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetArtifactValuesSchema 获取 Helm Chart 的 values schema
|
||||
// @Summary 获取 Helm Chart Values Schema
|
||||
// @Description 获取 Helm Chart 的 values.schema.json (仅支持 Chart 类型)
|
||||
// @Tags Artifacts
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param repository_name path string true "Repository Name (URL encoded)"
|
||||
// @Param reference path string true "Artifact Reference (tag or digest)"
|
||||
// @Success 200 {object} dto.ValuesSchemaResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-schema [get]
|
||||
func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
repositoryName := vars["repository_name"]
|
||||
reference := vars["reference"]
|
||||
|
||||
schema, err := h.artifactService.GetValuesSchema(r.Context(), registryID, repositoryName, reference)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, entity.ErrRegistryNotFound),
|
||||
errors.Is(err, entity.ErrRepositoryNotFound),
|
||||
errors.Is(err, entity.ErrArtifactNotFound),
|
||||
errors.Is(err, entity.ErrValuesSchemaNotFound):
|
||||
respondError(w, http.StatusNotFound, "Values schema not found", err.Error())
|
||||
default:
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get values schema", err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.ValuesSchemaResponse{
|
||||
Schema: schema,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
127
backend/internal/adapter/input/http/rest/auth_handler.go
Normal file
127
backend/internal/adapter/input/http/rest/auth_handler.go
Normal file
@ -0,0 +1,127 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// AuthHandler 认证 Handler
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建认证 Handler
|
||||
func NewAuthHandler(authService *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
authService: authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 用户注册
|
||||
// @Summary 用户注册
|
||||
// @Description 创建一个新的后台用户
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.RegisterRequest true "注册信息"
|
||||
// @Success 201 {object} dto.UserResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /auth/register [post]
|
||||
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
user, err := h.authService.Register(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := &dto.UserResponse{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
Email: user.Email,
|
||||
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
// @Summary 用户登录
|
||||
// @Description 使用用户名和密码获取访问令牌
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.LoginRequest true "登录信息"
|
||||
// @Success 200 {object} dto.AuthResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
// TODO: 从 token 解析用户信息或从服务获取
|
||||
|
||||
// 返回响应
|
||||
response := &dto.AuthResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
Username: req.Username,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
// @Summary 刷新访问令牌
|
||||
// @Description 使用刷新令牌获取新的访问令牌
|
||||
// @Tags Auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.RefreshTokenRequest true "刷新令牌"
|
||||
// @Success 200 {object} dto.AuthResponse
|
||||
// @Failure 401 {object} dto.ErrorResponse
|
||||
// @Router /auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.RefreshTokenRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := &dto.AuthResponse{
|
||||
AccessToken: newAccessToken,
|
||||
RefreshToken: req.RefreshToken,
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
221
backend/internal/adapter/input/http/rest/cluster_handler.go
Normal file
221
backend/internal/adapter/input/http/rest/cluster_handler.go
Normal file
@ -0,0 +1,221 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// ClusterHandler 集群 Handler
|
||||
type ClusterHandler struct {
|
||||
clusterService *service.ClusterService
|
||||
}
|
||||
|
||||
// NewClusterHandler 创建集群 Handler
|
||||
func NewClusterHandler(clusterService *service.ClusterService) *ClusterHandler {
|
||||
return &ClusterHandler{
|
||||
clusterService: clusterService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCluster 创建集群
|
||||
// @Summary 创建集群
|
||||
// @Description 创建一个新的 Kubernetes 集群配置
|
||||
// @Tags Clusters
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.CreateClusterRequest true "集群信息"
|
||||
// @Success 201 {object} dto.ClusterResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /clusters [post]
|
||||
func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateClusterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
|
||||
// 创建实体
|
||||
cluster := entity.NewCluster(req.Name, req.Host)
|
||||
cluster.Description = req.Description
|
||||
|
||||
if req.CertData != "" && req.KeyData != "" {
|
||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||
} else if req.Token != "" {
|
||||
cluster.SetTokenAuth(req.Token)
|
||||
} else if os.Getenv("ADAPTER_MODE") == "mock" {
|
||||
// Mock 模式:如果没有提供认证信息,使用默认的 Mock 证书
|
||||
cluster.SetCertAuth(
|
||||
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ==",
|
||||
"LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=",
|
||||
"LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t",
|
||||
)
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.clusterService.CreateCluster(r.Context(), cluster); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create cluster", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := h.toClusterResponse(cluster)
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetCluster 获取集群详情
|
||||
// @Summary 获取集群详情
|
||||
// @Tags Clusters
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 200 {object} dto.ClusterResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id} [get]
|
||||
func (h *ClusterHandler) GetCluster(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := h.toClusterResponse(cluster)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllClusters 获取所有集群
|
||||
// @Summary 列出所有集群
|
||||
// @Tags Clusters
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.ClusterResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /clusters [get]
|
||||
func (h *ClusterHandler) GetAllClusters(w http.ResponseWriter, r *http.Request) {
|
||||
clusters, err := h.clusterService.ListClusters(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list clusters", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.ClusterResponse, 0, len(clusters))
|
||||
for _, cluster := range clusters {
|
||||
responses = append(responses, h.toClusterResponse(cluster))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// UpdateCluster 更新集群
|
||||
// @Summary 更新集群
|
||||
// @Tags Clusters
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param request body dto.UpdateClusterRequest true "更新内容"
|
||||
// @Success 200 {object} dto.ClusterResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id} [put]
|
||||
func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
var req dto.UpdateClusterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
|
||||
// 获取现有集群
|
||||
cluster, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
cluster.Update(req.Name, req.Host, req.Description)
|
||||
|
||||
if req.CertData != "" && req.KeyData != "" {
|
||||
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
|
||||
} else if req.Token != "" {
|
||||
cluster.SetTokenAuth(req.Token)
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.clusterService.UpdateCluster(r.Context(), cluster); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update cluster", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := h.toClusterResponse(cluster)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteCluster 删除集群
|
||||
// @Summary 删除集群
|
||||
// @Tags Clusters
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id} [delete]
|
||||
func (h *ClusterHandler) DeleteCluster(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
if err := h.clusterService.DeleteCluster(r.Context(), clusterID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete cluster", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetClusterHealth 获取集群健康状态
|
||||
// @Summary 获取集群健康状态
|
||||
// @Tags Clusters
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 200 {object} dto.ClusterHealthResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/health [get]
|
||||
func (h *ClusterHandler) GetClusterHealth(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
// 检查集群是否存在
|
||||
_, err := h.clusterService.GetCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Cluster not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现真实的健康检查
|
||||
response := &dto.ClusterHealthResponse{
|
||||
Healthy: true,
|
||||
Message: "Cluster is healthy",
|
||||
Version: "v1.28.0",
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// toClusterResponse 将 Cluster 实体转换为响应 DTO(脱敏)
|
||||
func (h *ClusterHandler) toClusterResponse(cluster *entity.Cluster) *dto.ClusterResponse {
|
||||
return dto.ToClusterResponse(cluster)
|
||||
}
|
||||
371
backend/internal/adapter/input/http/rest/instance_handler.go
Normal file
371
backend/internal/adapter/input/http/rest/instance_handler.go
Normal file
@ -0,0 +1,371 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// InstanceHandler 实例 Handler
|
||||
type InstanceHandler struct {
|
||||
instanceService *service.InstanceService
|
||||
}
|
||||
|
||||
// NewInstanceHandler 创建实例 Handler
|
||||
func NewInstanceHandler(instanceService *service.InstanceService) *InstanceHandler {
|
||||
return &InstanceHandler{
|
||||
instanceService: instanceService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateInstance 创建实例
|
||||
// @Summary 创建实例
|
||||
// @Description 在指定集群上部署一个 artifact
|
||||
// @Tags Instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param request body dto.CreateInstanceRequest true "实例配置"
|
||||
// @Success 201 {object} dto.InstanceResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances [post]
|
||||
func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
var req dto.CreateInstanceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
req.Normalize()
|
||||
|
||||
// Extract chart name from repository (e.g., "charts/nginx" -> "nginx")
|
||||
chart := req.Repository
|
||||
if lastSlash := strings.LastIndex(req.Repository, "/"); lastSlash != -1 {
|
||||
chart = req.Repository[lastSlash+1:]
|
||||
}
|
||||
|
||||
// 创建实体
|
||||
instance := entity.NewInstance(
|
||||
clusterID,
|
||||
req.Name,
|
||||
req.Namespace,
|
||||
req.RegistryID,
|
||||
req.Repository,
|
||||
chart, // Extracted chart name
|
||||
req.Tag, // Tag mapped to version
|
||||
)
|
||||
instance.Description = req.Description
|
||||
|
||||
if req.Values != nil {
|
||||
instance.SetValues(req.Values)
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
instance.SetValuesYAML(req.ValuesYAML)
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.instanceService.CreateInstance(r.Context(), instance); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create instance", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
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"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetInstance 获取实例详情
|
||||
// @Summary 获取实例详情
|
||||
// @Tags Instances
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param instance_id path string true "实例 ID"
|
||||
// @Success 200 {object} dto.InstanceResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
|
||||
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
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"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ListInstances 列出集群的所有实例
|
||||
// @Summary 列出实例
|
||||
// @Tags Instances
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 200 {object} dto.InstanceListResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances [get]
|
||||
func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.InstanceResponse, 0, len(instances))
|
||||
for _, instance := range instances {
|
||||
responses = append(responses, &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
LastOperation: string(instance.LastOperation),
|
||||
LastError: instance.LastError,
|
||||
Revision: instance.Revision,
|
||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
}
|
||||
|
||||
response := &dto.InstanceListResponse{
|
||||
Instances: responses,
|
||||
Total: len(responses),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// UpdateInstance 更新实例
|
||||
// @Summary 更新实例
|
||||
// @Tags Instances
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param instance_id path string true "实例 ID"
|
||||
// @Param request body dto.UpdateInstanceRequest true "更新内容"
|
||||
// @Success 200 {object} dto.InstanceResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances/{instance_id} [put]
|
||||
func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
var req dto.UpdateInstanceRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取现有实例
|
||||
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Instance not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
if req.Version != "" {
|
||||
instance.Upgrade(req.Version, req.Values)
|
||||
}
|
||||
if req.Description != "" {
|
||||
instance.Description = req.Description
|
||||
}
|
||||
if req.ValuesYAML != "" {
|
||||
instance.SetValuesYAML(req.ValuesYAML)
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.instanceService.UpdateInstance(r.Context(), instance); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update instance", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := &dto.InstanceResponse{
|
||||
ID: instance.ID,
|
||||
ClusterID: instance.ClusterID,
|
||||
Name: instance.Name,
|
||||
Namespace: instance.Namespace,
|
||||
RegistryID: instance.RegistryID,
|
||||
Repository: instance.Repository,
|
||||
Chart: instance.Chart,
|
||||
Version: instance.Version,
|
||||
Description: instance.Description,
|
||||
Status: string(instance.Status),
|
||||
StatusReason: instance.StatusReason,
|
||||
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"),
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteInstance 删除实例
|
||||
// @Summary 删除实例
|
||||
// @Tags Instances
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param instance_id path string true "实例 ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances/{instance_id} [delete]
|
||||
func (h *InstanceHandler) DeleteInstance(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
if err := h.instanceService.DeleteInstance(r.Context(), instanceID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete instance", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListInstanceEntries 获取实例入口
|
||||
// @Summary 获取实例 Service/Ingress 入口
|
||||
// @Tags Instances
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Param instance_id path string true "实例 ID"
|
||||
// @Success 200 {array} dto.InstanceEntryResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /clusters/{cluster_id}/instances/{instance_id}/entries [get]
|
||||
func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
instanceID := vars["instance_id"]
|
||||
|
||||
entries, err := h.instanceService.ListInstanceEntries(r.Context(), clusterID, instanceID)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
switch err {
|
||||
case entity.ErrInstanceNotFound:
|
||||
status = http.StatusNotFound
|
||||
case entity.ErrClusterNotFound:
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
respondError(w, status, "Failed to list instance entries", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
responses := make([]*dto.InstanceEntryResponse, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
responses = append(responses, convertInstanceEntry(entry))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
|
||||
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
|
||||
for _, port := range entry.Ports {
|
||||
portResponses = append(portResponses, dto.InstanceEntryPortResponse{
|
||||
Name: port.Name,
|
||||
Protocol: port.Protocol,
|
||||
Port: port.Port,
|
||||
TargetPort: port.TargetPort,
|
||||
NodePort: port.NodePort,
|
||||
})
|
||||
}
|
||||
|
||||
hostResponses := make([]dto.InstanceEntryHostResponse, 0, len(entry.Hosts))
|
||||
for _, host := range entry.Hosts {
|
||||
pathResponses := make([]dto.InstanceEntryPathResponse, 0, len(host.Paths))
|
||||
for _, path := range host.Paths {
|
||||
pathResponses = append(pathResponses, dto.InstanceEntryPathResponse{
|
||||
Path: path.Path,
|
||||
ServiceName: path.ServiceName,
|
||||
ServicePort: path.ServicePort,
|
||||
})
|
||||
}
|
||||
hostResponses = append(hostResponses, dto.InstanceEntryHostResponse{
|
||||
Host: host.Host,
|
||||
Paths: pathResponses,
|
||||
})
|
||||
}
|
||||
|
||||
tlsResponses := make([]dto.InstanceEntryTLSResponse, 0, len(entry.TLS))
|
||||
for _, tls := range entry.TLS {
|
||||
tlsResponses = append(tlsResponses, dto.InstanceEntryTLSResponse{
|
||||
Hosts: tls.Hosts,
|
||||
SecretName: tls.SecretName,
|
||||
})
|
||||
}
|
||||
|
||||
return &dto.InstanceEntryResponse{
|
||||
Kind: entry.Kind,
|
||||
Name: entry.Name,
|
||||
Namespace: entry.Namespace,
|
||||
Type: entry.Type,
|
||||
ClusterIP: entry.ClusterIP,
|
||||
ExternalIPs: entry.ExternalIPs,
|
||||
LoadBalancerIngress: entry.LoadBalancerIngress,
|
||||
Ports: portResponses,
|
||||
Hosts: hostResponses,
|
||||
TLS: tlsResponses,
|
||||
}
|
||||
}
|
||||
137
backend/internal/adapter/input/http/rest/monitoring_handler.go
Normal file
137
backend/internal/adapter/input/http/rest/monitoring_handler.go
Normal file
@ -0,0 +1,137 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// MonitoringHandler 监控处理器
|
||||
type MonitoringHandler struct {
|
||||
monitoringService *service.MonitoringService
|
||||
}
|
||||
|
||||
// NewMonitoringHandler 创建监控处理器
|
||||
func NewMonitoringHandler(monitoringService *service.MonitoringService) *MonitoringHandler {
|
||||
return &MonitoringHandler{
|
||||
monitoringService: monitoringService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterMonitoring 获取单个集群的监控信息
|
||||
// @Summary 获取集群监控
|
||||
// @Tags Monitoring
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 200 {object} dto.ClusterMetricsResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /monitoring/clusters/{cluster_id} [get]
|
||||
func (h *MonitoringHandler) GetClusterMonitoring(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
metrics, err := h.monitoringService.GetClusterMonitoring(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.ToClusterMetricsResponse(metrics)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ListClusterMonitoring 获取所有集群的监控信息
|
||||
// @Summary 列出集群监控
|
||||
// @Tags Monitoring
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.ClusterMetricsResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /monitoring/clusters [get]
|
||||
func (h *MonitoringHandler) ListClusterMonitoring(w http.ResponseWriter, r *http.Request) {
|
||||
monitoringList, err := h.monitoringService.ListClusterMonitoring(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
response := make([]*dto.ClusterMetricsResponse, len(monitoringList))
|
||||
for i, m := range monitoringList {
|
||||
response[i] = dto.ToClusterMetricsResponse(m)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetMonitoringSummary 获取监控汇总信息
|
||||
// @Summary 获取监控汇总
|
||||
// @Tags Monitoring
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} dto.MonitoringSummaryResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /monitoring/summary [get]
|
||||
func (h *MonitoringHandler) GetMonitoringSummary(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err := h.monitoringService.GetMonitoringSummary(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.ToMonitoringSummaryResponse(summary)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetNodeMetrics 获取集群的节点指标
|
||||
// @Summary 获取节点指标
|
||||
// @Tags Monitoring
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param cluster_id path string true "集群 ID"
|
||||
// @Success 200 {array} dto.NodeMetricsResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /monitoring/clusters/{cluster_id}/nodes [get]
|
||||
func (h *MonitoringHandler) GetNodeMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
clusterID := vars["cluster_id"]
|
||||
|
||||
nodes, err := h.monitoringService.GetNodeMetrics(r.Context(), clusterID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "MONITORING_ERROR", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
response := make([]dto.NodeMetricsResponse, len(nodes))
|
||||
for i, node := range nodes {
|
||||
response[i] = dto.NodeMetricsResponse{
|
||||
NodeName: node.NodeName,
|
||||
Status: node.Status,
|
||||
Role: node.Role,
|
||||
Age: node.Age,
|
||||
PodCount: node.PodCount,
|
||||
CPUCapacity: node.CPUCapacity,
|
||||
CPUAllocatable: node.CPUAllocatable,
|
||||
CPUUsage: node.CPUUsage,
|
||||
CPUPercent: node.CPUPercent,
|
||||
MemoryCapacity: node.MemoryCapacity,
|
||||
MemoryAllocatable: node.MemoryAllocatable,
|
||||
MemoryUsage: node.MemoryUsage,
|
||||
MemoryPercent: node.MemoryPercent,
|
||||
GPUCapacity: node.GPUCapacity,
|
||||
GPUUsage: node.GPUUsage,
|
||||
GPUPercent: node.GPUPercent,
|
||||
GPUType: node.GPUType,
|
||||
OSImage: node.OSImage,
|
||||
KernelVersion: node.KernelVersion,
|
||||
ContainerRuntime: node.ContainerRuntime,
|
||||
KubeletVersion: node.KubeletVersion,
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
201
backend/internal/adapter/input/http/rest/registry_handler.go
Normal file
201
backend/internal/adapter/input/http/rest/registry_handler.go
Normal file
@ -0,0 +1,201 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
||||
)
|
||||
|
||||
// RegistryHandler Registry Handler
|
||||
type RegistryHandler struct {
|
||||
registryService *service.RegistryService
|
||||
}
|
||||
|
||||
// NewRegistryHandler 创建 Registry Handler
|
||||
func NewRegistryHandler(registryService *service.RegistryService) *RegistryHandler {
|
||||
return &RegistryHandler{
|
||||
registryService: registryService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRegistry 创建 Registry
|
||||
// @Summary 创建 Registry
|
||||
// @Description 新增 OCI Registry 配置
|
||||
// @Tags Registries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.CreateRegistryRequest true "Registry 信息"
|
||||
// @Success 201 {object} dto.RegistryResponse
|
||||
// @Failure 400 {object} dto.ErrorResponse
|
||||
// @Router /registries [post]
|
||||
func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
var req dto.CreateRegistryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 创建实体
|
||||
registry := entity.NewRegistry(req.Name, req.URL)
|
||||
registry.Description = req.Description
|
||||
registry.Insecure = req.Insecure
|
||||
registry.SetCredentials(req.Username, req.Password)
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.registryService.CreateRegistry(r.Context(), registry); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to create registry", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应(脱敏)
|
||||
response := dto.ToRegistryResponse(registry)
|
||||
respondJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
// GetRegistry 获取 Registry 详情
|
||||
// @Summary 获取 Registry
|
||||
// @Tags Registries
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Success 200 {object} dto.RegistryResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id} [get]
|
||||
func (h *RegistryHandler) GetRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
registry, err := h.registryService.GetRegistry(r.Context(), registryID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Registry not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应(脱敏)
|
||||
response := dto.ToRegistryResponse(registry)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// GetAllRegistries 获取所有 Registries
|
||||
// @Summary 列出所有 Registries
|
||||
// @Tags Registries
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} dto.RegistryResponse
|
||||
// @Failure 500 {object} dto.ErrorResponse
|
||||
// @Router /registries [get]
|
||||
func (h *RegistryHandler) GetAllRegistries(w http.ResponseWriter, r *http.Request) {
|
||||
registries, err := h.registryService.ListRegistries(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to list registries", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应(脱敏)
|
||||
responses := make([]*dto.RegistryResponse, 0, len(registries))
|
||||
for _, registry := range registries {
|
||||
responses = append(responses, dto.ToRegistryResponse(registry))
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, responses)
|
||||
}
|
||||
|
||||
// UpdateRegistry 更新 Registry
|
||||
// @Summary 更新 Registry
|
||||
// @Tags Registries
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Param request body dto.UpdateRegistryRequest true "更新内容"
|
||||
// @Success 200 {object} dto.RegistryResponse
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id} [put]
|
||||
func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
var req dto.UpdateRegistryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 获取现有 Registry
|
||||
registry, err := h.registryService.GetRegistry(r.Context(), registryID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "Registry not found", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
registry.Update(req.Name, req.URL, req.Description)
|
||||
registry.Insecure = req.Insecure
|
||||
if req.Username != "" || req.Password != "" {
|
||||
registry.SetCredentials(req.Username, req.Password)
|
||||
}
|
||||
|
||||
// 调用领域服务
|
||||
if err := h.registryService.UpdateRegistry(r.Context(), registry); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Failed to update registry", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 返回响应(脱敏)
|
||||
response := dto.ToRegistryResponse(registry)
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// DeleteRegistry 删除 Registry
|
||||
// @Summary 删除 Registry
|
||||
// @Tags Registries
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Success 204 {string} string "No Content"
|
||||
// @Failure 404 {object} dto.ErrorResponse
|
||||
// @Router /registries/{registry_id} [delete]
|
||||
func (h *RegistryHandler) DeleteRegistry(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
if err := h.registryService.DeleteRegistry(r.Context(), registryID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "Failed to delete registry", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetRegistryHealth 获取 Registry 健康状态
|
||||
// @Summary 检查 Registry 健康
|
||||
// @Tags Registries
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param registry_id path string true "Registry ID"
|
||||
// @Success 200 {object} dto.RegistryHealthResponse
|
||||
// @Router /registries/{registry_id}/health [get]
|
||||
func (h *RegistryHandler) GetRegistryHealth(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
registryID := vars["registry_id"]
|
||||
|
||||
// 调用领域服务检查健康状态
|
||||
err := h.registryService.CheckHealth(r.Context(), registryID)
|
||||
|
||||
response := &dto.RegistryHealthResponse{
|
||||
Healthy: err == nil,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.Message = err.Error()
|
||||
} else {
|
||||
response.Message = "Registry is healthy"
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
89
backend/internal/adapter/input/http/rest/swagger-ui.html
Normal file
89
backend/internal/adapter/input/http/rest/swagger-ui.html
Normal file
@ -0,0 +1,89 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OCDP Backend API - Swagger UI</title>
|
||||
<link rel="stylesheet" href="/api/docs/assets/swagger-ui.css">
|
||||
<style>
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
#swagger-ui {
|
||||
max-width: 1460px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.swagger-ui .info .title {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.swagger-ui .info {
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.15);
|
||||
margin: 0 0 20px;
|
||||
padding: 30px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/api/docs/assets/swagger-ui-bundle.js"></script>
|
||||
<script src="/api/docs/assets/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {
|
||||
// Build a system
|
||||
const ui = SwaggerUIBundle({
|
||||
url: "/api/docs/openapi.yaml",
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: true,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
defaultModelsExpandDepth: 1,
|
||||
defaultModelExpandDepth: 1,
|
||||
docExpansion: "list",
|
||||
filter: true,
|
||||
showRequestHeaders: true,
|
||||
tryItOutEnabled: true,
|
||||
persistAuthorization: true,
|
||||
supportedSubmitMethods: ['get', 'post', 'put', 'delete', 'patch', 'head', 'options'],
|
||||
validatorUrl: null,
|
||||
onComplete: function() {
|
||||
console.log("Swagger UI loaded successfully");
|
||||
}
|
||||
});
|
||||
|
||||
window.ui = ui;
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
68
backend/internal/adapter/input/http/rest/swagger_handler.go
Normal file
68
backend/internal/adapter/input/http/rest/swagger_handler.go
Normal file
@ -0,0 +1,68 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
|
||||
repoDocs "github.com/ocdp/cluster-service/docs"
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed swagger-ui.html
|
||||
swaggerHTML []byte
|
||||
|
||||
//go:embed swaggerui/swagger-ui.css
|
||||
swaggerCSS []byte
|
||||
|
||||
//go:embed swaggerui/swagger-ui-bundle.js
|
||||
swaggerBundleJS []byte
|
||||
|
||||
//go:embed swaggerui/swagger-ui-standalone-preset.js
|
||||
swaggerStandalonePresetJS []byte
|
||||
)
|
||||
|
||||
// SwaggerHandler Swagger UI Handler
|
||||
type SwaggerHandler struct{}
|
||||
|
||||
// NewSwaggerHandler 创建 Swagger Handler
|
||||
func NewSwaggerHandler() *SwaggerHandler {
|
||||
return &SwaggerHandler{}
|
||||
}
|
||||
|
||||
// ServeSwaggerUI 提供 Swagger UI 页面
|
||||
func (h *SwaggerHandler) ServeSwaggerUI(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(swaggerHTML)
|
||||
}
|
||||
|
||||
// ServeSwaggerCSS 提供 Swagger UI 样式
|
||||
func (h *SwaggerHandler) ServeSwaggerCSS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(swaggerCSS)
|
||||
}
|
||||
|
||||
// ServeSwaggerBundle 提供 Swagger UI 主脚本
|
||||
func (h *SwaggerHandler) ServeSwaggerBundle(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(swaggerBundleJS)
|
||||
}
|
||||
|
||||
// ServeSwaggerStandalonePreset 提供 Swagger UI 预设脚本
|
||||
func (h *SwaggerHandler) ServeSwaggerStandalonePreset(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(swaggerStandalonePresetJS)
|
||||
}
|
||||
|
||||
// ServeOpenAPISpec 提供 OpenAPI 规范文件
|
||||
func (h *SwaggerHandler) ServeOpenAPISpec(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(repoDocs.OpenAPISpec)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
35
backend/internal/adapter/input/http/rest/utils.go
Normal file
35
backend/internal/adapter/input/http/rest/utils.go
Normal file
@ -0,0 +1,35 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
|
||||
)
|
||||
|
||||
// respondJSON 返回 JSON 响应
|
||||
func respondJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// respondError 返回错误响应
|
||||
func respondError(w http.ResponseWriter, statusCode int, error string, message string) {
|
||||
response := &dto.ErrorResponse{
|
||||
Error: error,
|
||||
Message: message,
|
||||
Code: statusCode,
|
||||
}
|
||||
respondJSON(w, statusCode, response)
|
||||
}
|
||||
|
||||
// respondSuccess 返回成功响应
|
||||
func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
|
||||
response := &dto.SuccessResponse{
|
||||
Message: message,
|
||||
Data: data,
|
||||
}
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
220
backend/internal/adapter/output/factory.go
Normal file
220
backend/internal/adapter/output/factory.go
Normal file
@ -0,0 +1,220 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
helmMock "github.com/ocdp/cluster-service/internal/adapter/output/helm/mock"
|
||||
helmReal "github.com/ocdp/cluster-service/internal/adapter/output/helm/real"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output/k8s"
|
||||
ociMock "github.com/ocdp/cluster-service/internal/adapter/output/oci/mock"
|
||||
ociReal "github.com/ocdp/cluster-service/internal/adapter/output/oci/real"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output/persistence/postgres"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// AdapterMode 适配器模式
|
||||
type AdapterMode string
|
||||
|
||||
const (
|
||||
ModeMock AdapterMode = "mock" // Mock 模式(内存存储,用于开发调试)
|
||||
// 默认模式:连接真实 PostgreSQL 和服务(任何非 "mock" 的值都是默认模式)
|
||||
)
|
||||
|
||||
// AdapterFactory 适配器工厂
|
||||
// 用于创建所有 Output Adapters,支持 Mock 和真实实现切换
|
||||
type AdapterFactory struct {
|
||||
mode AdapterMode
|
||||
encryptor crypto.Encryptor // 加密器(用于敏感数据加密)
|
||||
|
||||
// 数据库连接字符串(非 Mock 模式需要)
|
||||
dbConnString string
|
||||
|
||||
// 数据库连接(非 Mock 模式)
|
||||
db *postgres.DB
|
||||
}
|
||||
|
||||
// NewAdapterFactory 创建适配器工厂
|
||||
func NewAdapterFactory(mode AdapterMode, encryptor crypto.Encryptor, dbConnString string) *AdapterFactory {
|
||||
return &AdapterFactory{
|
||||
mode: mode,
|
||||
encryptor: encryptor,
|
||||
dbConnString: dbConnString,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUserRepository 创建用户仓储
|
||||
func (f *AdapterFactory) CreateUserRepository() (repository.UserRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewUserRepositoryMock(), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewUserRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateClusterRepository 创建集群仓储
|
||||
func (f *AdapterFactory) CreateClusterRepository() (repository.ClusterRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewClusterRepositoryMock(f.encryptor), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewClusterRepository(f.db, f.encryptor), nil
|
||||
}
|
||||
|
||||
// CreateRegistryRepository 创建 Registry 仓储
|
||||
func (f *AdapterFactory) CreateRegistryRepository() (repository.RegistryRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewRegistryRepositoryMock(f.encryptor), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewRegistryRepository(f.db, f.encryptor), nil
|
||||
}
|
||||
|
||||
// CreateInstanceRepository 创建实例仓储
|
||||
func (f *AdapterFactory) CreateInstanceRepository() (repository.InstanceRepository, error) {
|
||||
if f.mode == ModeMock {
|
||||
return mock.NewInstanceRepositoryMock(), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(PostgreSQL)
|
||||
if err := f.ensureDBConnection(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return postgres.NewInstanceRepository(f.db), nil
|
||||
}
|
||||
|
||||
// CreateOCIClient 创建 OCI 客户端
|
||||
func (f *AdapterFactory) CreateOCIClient() (repository.OCIClient, error) {
|
||||
if f.mode == ModeMock {
|
||||
return ociMock.NewOCIClientMock(), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(ORAS SDK)
|
||||
return ociReal.NewOCIClient(), nil
|
||||
}
|
||||
|
||||
// CreateHelmClient 创建 Helm 客户端
|
||||
func (f *AdapterFactory) CreateHelmClient() (repository.HelmClient, error) {
|
||||
if f.mode == ModeMock {
|
||||
return helmMock.NewHelmClientMock(), nil
|
||||
}
|
||||
|
||||
// 默认:真实实现(Helm SDK)
|
||||
return helmReal.NewHelmClient(), nil
|
||||
}
|
||||
|
||||
// CreateMetricsClient 创建 Metrics 客户端
|
||||
func (f *AdapterFactory) CreateMetricsClient(clusterRepo repository.ClusterRepository) repository.MetricsClient {
|
||||
// Metrics client 总是使用真实的 Kubernetes API
|
||||
return k8s.NewMetricsClient(clusterRepo)
|
||||
}
|
||||
|
||||
// CreateEntryClient 创建实例入口查询客户端
|
||||
func (f *AdapterFactory) CreateEntryClient() repository.InstanceEntryClient {
|
||||
return k8s.NewEntryClient()
|
||||
}
|
||||
|
||||
// CreateAllRepositories 一次性创建所有 Repositories
|
||||
func (f *AdapterFactory) CreateAllRepositories() (*Repositories, error) {
|
||||
userRepo, err := f.CreateUserRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create user repository: %w", err)
|
||||
}
|
||||
|
||||
clusterRepo, err := f.CreateClusterRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cluster repository: %w", err)
|
||||
}
|
||||
|
||||
registryRepo, err := f.CreateRegistryRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create registry repository: %w", err)
|
||||
}
|
||||
|
||||
instanceRepo, err := f.CreateInstanceRepository()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create instance repository: %w", err)
|
||||
}
|
||||
|
||||
ociClient, err := f.CreateOCIClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OCI client: %w", err)
|
||||
}
|
||||
|
||||
helmClient, err := f.CreateHelmClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Helm client: %w", err)
|
||||
}
|
||||
|
||||
// 创建 Metrics client(依赖 clusterRepo)
|
||||
metricsClient := f.CreateMetricsClient(clusterRepo)
|
||||
entryClient := f.CreateEntryClient()
|
||||
|
||||
return &Repositories{
|
||||
UserRepo: userRepo,
|
||||
ClusterRepo: clusterRepo,
|
||||
RegistryRepo: registryRepo,
|
||||
InstanceRepo: instanceRepo,
|
||||
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
|
||||
}
|
||||
|
||||
// ensureDBConnection 确保数据库连接已建立
|
||||
func (f *AdapterFactory) ensureDBConnection() error {
|
||||
if f.db != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if f.dbConnString == "" {
|
||||
return fmt.Errorf("database connection string is required (set DATABASE_URL environment variable)")
|
||||
}
|
||||
|
||||
db, err := postgres.NewDB(f.dbConnString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
// 初始化数据库 schema
|
||||
if err := db.InitSchema(); err != nil {
|
||||
return fmt.Errorf("failed to initialize database schema: %w", err)
|
||||
}
|
||||
|
||||
f.db = db
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 关闭工厂资源
|
||||
func (f *AdapterFactory) Close() error {
|
||||
if f.db != nil {
|
||||
return f.db.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
196
backend/internal/adapter/output/helm/mock/helm_client_mock.go
Normal file
196
backend/internal/adapter/output/helm/mock/helm_client_mock.go
Normal file
@ -0,0 +1,196 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// HelmClientMock Helm 客户端 Mock 实现
|
||||
type HelmClientMock struct {
|
||||
// Mock 数据存储
|
||||
releases map[string]map[string]*entity.Instance // clusterID -> releaseName -> instance
|
||||
history map[string]map[string][]*entity.ReleaseHistory // clusterID -> releaseName -> []history
|
||||
}
|
||||
|
||||
// NewHelmClientMock 创建 Mock 实现
|
||||
func NewHelmClientMock() repository.HelmClient {
|
||||
return &HelmClientMock{
|
||||
releases: make(map[string]map[string]*entity.Instance),
|
||||
history: make(map[string]map[string][]*entity.ReleaseHistory),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
// 初始化集群数据
|
||||
if c.releases[cluster.ID] == nil {
|
||||
c.releases[cluster.ID] = make(map[string]*entity.Instance)
|
||||
c.history[cluster.ID] = make(map[string][]*entity.ReleaseHistory)
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
key := fmt.Sprintf("%s/%s", instance.Namespace, instance.Name)
|
||||
if _, exists := c.releases[cluster.ID][key]; exists {
|
||||
return entity.ErrInstanceExists
|
||||
}
|
||||
|
||||
// Mock 安装
|
||||
instance.Status = entity.StatusDeployed
|
||||
instance.Revision = 1
|
||||
instance.UpdatedAt = time.Now()
|
||||
|
||||
c.releases[cluster.ID][key] = instance
|
||||
|
||||
// 添加历史记录
|
||||
c.history[cluster.ID][key] = []*entity.ReleaseHistory{
|
||||
{
|
||||
Revision: 1,
|
||||
Updated: time.Now(),
|
||||
Status: entity.StatusDeployed,
|
||||
Chart: fmt.Sprintf("%s-%s", instance.Chart, instance.Version),
|
||||
AppVersion: instance.Version,
|
||||
Description: "Install complete",
|
||||
},
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
key := fmt.Sprintf("%s/%s", instance.Namespace, instance.Name)
|
||||
|
||||
existing, exists := c.releases[cluster.ID][key]
|
||||
if !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// Mock 升级
|
||||
instance.Revision = existing.Revision + 1
|
||||
instance.Status = entity.StatusDeployed
|
||||
instance.UpdatedAt = time.Now()
|
||||
|
||||
c.releases[cluster.ID][key] = instance
|
||||
|
||||
// 添加历史记录
|
||||
history := &entity.ReleaseHistory{
|
||||
Revision: instance.Revision,
|
||||
Updated: time.Now(),
|
||||
Status: entity.StatusDeployed,
|
||||
Chart: fmt.Sprintf("%s-%s", instance.Chart, instance.Version),
|
||||
AppVersion: instance.Version,
|
||||
Description: "Upgrade complete",
|
||||
}
|
||||
c.history[cluster.ID][key] = append(c.history[cluster.ID][key], history)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||
|
||||
if _, exists := c.releases[cluster.ID][key]; !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// Mock 卸载
|
||||
delete(c.releases[cluster.ID], key)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||
|
||||
instance, exists := c.releases[cluster.ID][key]
|
||||
if !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 检查历史记录是否存在
|
||||
histories := c.history[cluster.ID][key]
|
||||
if revision > len(histories) || revision < 1 {
|
||||
return fmt.Errorf("revision %d not found", revision)
|
||||
}
|
||||
|
||||
// Mock 回滚
|
||||
instance.Revision = len(histories) + 1
|
||||
instance.Status = entity.StatusDeployed
|
||||
instance.UpdatedAt = time.Now()
|
||||
|
||||
c.releases[cluster.ID][key] = instance
|
||||
|
||||
// 添加回滚历史记录
|
||||
history := &entity.ReleaseHistory{
|
||||
Revision: instance.Revision,
|
||||
Updated: time.Now(),
|
||||
Status: entity.StatusDeployed,
|
||||
Chart: instance.Chart,
|
||||
AppVersion: instance.Version,
|
||||
Description: fmt.Sprintf("Rollback to revision %d", revision),
|
||||
}
|
||||
c.history[cluster.ID][key] = append(c.history[cluster.ID][key], history)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||
|
||||
instance, exists := c.releases[cluster.ID][key]
|
||||
if !exists {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||
|
||||
if _, exists := c.releases[cluster.ID][key]; !exists {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
histories := c.history[cluster.ID][key]
|
||||
if histories == nil {
|
||||
return []*entity.ReleaseHistory{}, nil
|
||||
}
|
||||
|
||||
return histories, nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||
clusterReleases := c.releases[cluster.ID]
|
||||
if clusterReleases == nil {
|
||||
return []*entity.Instance{}, nil
|
||||
}
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for key, instance := range clusterReleases {
|
||||
// 如果指定了 namespace,只返回该 namespace 的
|
||||
if namespace != "" && namespace != "all" {
|
||||
keyNamespace := instance.Namespace
|
||||
if keyNamespace != namespace {
|
||||
continue
|
||||
}
|
||||
}
|
||||
instances = append(instances, c.releases[cluster.ID][key])
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
func (c *HelmClientMock) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||
key := fmt.Sprintf("%s/%s", namespace, releaseName)
|
||||
|
||||
instance, exists := c.releases[cluster.ID][key]
|
||||
if !exists {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return instance.Values, nil
|
||||
}
|
||||
|
||||
313
backend/internal/adapter/output/helm/real/helm_client.go
Normal file
313
backend/internal/adapter/output/helm/real/helm_client.go
Normal file
@ -0,0 +1,313 @@
|
||||
package real
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"helm.sh/helm/v3/pkg/action"
|
||||
"helm.sh/helm/v3/pkg/chart/loader"
|
||||
"helm.sh/helm/v3/pkg/cli"
|
||||
"helm.sh/helm/v3/pkg/release"
|
||||
"helm.sh/helm/v3/pkg/storage/driver"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/client-go/discovery"
|
||||
"k8s.io/client-go/discovery/cached/memory"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/restmapper"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
)
|
||||
|
||||
// HelmClient 真实的 Helm 客户端实现
|
||||
type HelmClient struct {
|
||||
settings *cli.EnvSettings
|
||||
}
|
||||
|
||||
// NewHelmClient 创建真实的 Helm 客户端
|
||||
func NewHelmClient() repository.HelmClient {
|
||||
return &HelmClient{
|
||||
settings: cli.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// getActionConfig 获取 Helm action configuration
|
||||
func (h *HelmClient) getActionConfig(cluster *entity.Cluster, namespace string) (*action.Configuration, error) {
|
||||
actionConfig := new(action.Configuration)
|
||||
|
||||
// 创建临时 kubeconfig 文件
|
||||
kubeconfigContent := cluster.GetKubeConfig()
|
||||
tmpDir, err := os.MkdirTemp("", "helm-kubeconfig-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
kubeconfigPath := filepath.Join(tmpDir, "kubeconfig")
|
||||
if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
// 使用 kubeconfig 初始化 action config
|
||||
if err := actionConfig.Init(
|
||||
&kubeconfigGetter{kubeconfigPath: kubeconfigPath},
|
||||
namespace,
|
||||
os.Getenv("HELM_DRIVER"), // storage driver: configmap, secret, memory
|
||||
func(format string, v ...interface{}) {
|
||||
// Log function
|
||||
},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize action config: %w", err)
|
||||
}
|
||||
|
||||
return actionConfig, nil
|
||||
}
|
||||
|
||||
// kubeconfigGetter implements RESTClientGetter
|
||||
type kubeconfigGetter struct {
|
||||
kubeconfigPath string
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToRESTConfig() (*rest.Config, error) {
|
||||
return clientcmd.BuildConfigFromFlags("", k.kubeconfigPath)
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
|
||||
config, err := k.ToRESTConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
discoveryClient := discovery.NewDiscoveryClientForConfigOrDie(config)
|
||||
// Wrap in a memory cache
|
||||
return memory.NewMemCacheClient(discoveryClient), nil
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToRESTMapper() (meta.RESTMapper, error) {
|
||||
discoveryClient, err := k.ToDiscoveryClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient)
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
func (k *kubeconfigGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig {
|
||||
return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
|
||||
&clientcmd.ClientConfigLoadingRules{ExplicitPath: k.kubeconfigPath},
|
||||
&clientcmd.ConfigOverrides{},
|
||||
)
|
||||
}
|
||||
|
||||
// Install 安装 Helm Chart
|
||||
func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
install := action.NewInstall(actionConfig)
|
||||
install.ReleaseName = instance.Name
|
||||
install.Namespace = instance.Namespace
|
||||
install.CreateNamespace = true
|
||||
install.Wait = true
|
||||
install.Timeout = 5 * 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)
|
||||
}
|
||||
|
||||
// 执行安装
|
||||
rel, err := install.Run(chart, instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install release: %w", err)
|
||||
}
|
||||
|
||||
// 更新 revision(状态由调用方根据操作结果设置)
|
||||
instance.Revision = rel.Version
|
||||
// 注意:不在这里设置 Status,让调用方通过 MarkSuccess/MarkFailure 来设置
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upgrade 升级 Helm Release
|
||||
func (h *HelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, instance.Namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
upgrade := action.NewUpgrade(actionConfig)
|
||||
upgrade.Namespace = instance.Namespace
|
||||
upgrade.Wait = true
|
||||
upgrade.Timeout = 5 * time.Minute
|
||||
|
||||
// 加载 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)
|
||||
}
|
||||
|
||||
// 执行升级
|
||||
rel, err := upgrade.Run(instance.Name, chart, instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upgrade release: %w", err)
|
||||
}
|
||||
|
||||
// 更新 revision(状态由调用方根据操作结果设置)
|
||||
instance.Revision = rel.Version
|
||||
// 注意:不在这里设置 Status,让调用方通过 MarkSuccess/MarkFailure 来设置
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Uninstall 卸载 Helm Release
|
||||
func (h *HelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
uninstall := action.NewUninstall(actionConfig)
|
||||
uninstall.Wait = true
|
||||
uninstall.Timeout = 5 * time.Minute
|
||||
|
||||
_, err = uninstall.Run(releaseName)
|
||||
if err != nil {
|
||||
if errors.Is(err, driver.ErrReleaseNotFound) {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to uninstall release: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rollback 回滚 Helm Release
|
||||
func (h *HelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rollback := action.NewRollback(actionConfig)
|
||||
rollback.Version = revision
|
||||
rollback.Wait = true
|
||||
rollback.Timeout = 5 * time.Minute
|
||||
|
||||
if err := rollback.Run(releaseName); err != nil {
|
||||
return fmt.Errorf("failed to rollback release: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus 获取 Release 状态
|
||||
func (h *HelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
status := action.NewStatus(actionConfig)
|
||||
rel, err := status.Run(releaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get release status: %w", err)
|
||||
}
|
||||
|
||||
return h.convertReleaseToInstance(rel), nil
|
||||
}
|
||||
|
||||
// GetHistory 获取 Release 历史
|
||||
func (h *HelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
history := action.NewHistory(actionConfig)
|
||||
history.Max = 256
|
||||
|
||||
releases, err := history.Run(releaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get release history: %w", err)
|
||||
}
|
||||
|
||||
result := make([]*entity.ReleaseHistory, 0, len(releases))
|
||||
for _, rel := range releases {
|
||||
result = append(result, &entity.ReleaseHistory{
|
||||
Revision: rel.Version,
|
||||
Updated: rel.Info.LastDeployed.Time,
|
||||
Status: entity.InstanceStatus(rel.Info.Status),
|
||||
Chart: rel.Chart.Metadata.Name,
|
||||
AppVersion: rel.Chart.Metadata.AppVersion,
|
||||
Description: rel.Info.Description,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// List 列出集群中的所有 Releases
|
||||
func (h *HelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
list := action.NewList(actionConfig)
|
||||
if namespace == "" {
|
||||
list.AllNamespaces = true
|
||||
}
|
||||
|
||||
releases, err := list.Run()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list releases: %w", err)
|
||||
}
|
||||
|
||||
instances := make([]*entity.Instance, 0, len(releases))
|
||||
for _, rel := range releases {
|
||||
instances = append(instances, h.convertReleaseToInstance(rel))
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// GetValues 获取 Release 的 values
|
||||
func (h *HelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||
actionConfig, err := h.getActionConfig(cluster, namespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
getValues := action.NewGetValues(actionConfig)
|
||||
values, err := getValues.Run(releaseName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get values: %w", err)
|
||||
}
|
||||
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// convertReleaseToInstance 转换 Helm Release 为 Instance
|
||||
func (h *HelmClient) convertReleaseToInstance(rel *release.Release) *entity.Instance {
|
||||
return &entity.Instance{
|
||||
Name: rel.Name,
|
||||
Namespace: rel.Namespace,
|
||||
Chart: rel.Chart.Metadata.Name,
|
||||
Version: rel.Chart.Metadata.Version,
|
||||
Status: entity.InstanceStatus(rel.Info.Status),
|
||||
Revision: rel.Version,
|
||||
Values: rel.Config,
|
||||
UpdatedAt: rel.Info.LastDeployed.Time,
|
||||
}
|
||||
}
|
||||
321
backend/internal/adapter/output/k8s/entry_client.go
Normal file
321
backend/internal/adapter/output/k8s/entry_client.go
Normal file
@ -0,0 +1,321 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
networkingv1 "k8s.io/api/networking/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/intstr"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// EntryClient 使用 Kubernetes API 查询实例相关 Service/Ingress
|
||||
type EntryClient struct{}
|
||||
|
||||
// NewEntryClient 创建 EntryClient
|
||||
func NewEntryClient() repository.InstanceEntryClient {
|
||||
return &EntryClient{}
|
||||
}
|
||||
|
||||
// ListEntries 查询实例的 Service/Ingress 入口
|
||||
func (c *EntryClient) ListEntries(
|
||||
ctx context.Context,
|
||||
cluster *entity.Cluster,
|
||||
instance *entity.Instance,
|
||||
) ([]*entity.InstanceEntry, error) {
|
||||
clientset, err := c.createClientset(cluster)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
selector := fmt.Sprintf("app.kubernetes.io/instance=%s", instance.Name)
|
||||
|
||||
serviceEntries, err := c.collectServiceEntries(ctx, clientset, instance, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ingressEntries, err := c.collectIngressEntries(ctx, clientset, instance, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return append(serviceEntries, ingressEntries...), nil
|
||||
}
|
||||
|
||||
func (c *EntryClient) collectServiceEntries(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
instance *entity.Instance,
|
||||
selector string,
|
||||
) ([]*entity.InstanceEntry, error) {
|
||||
services, err := c.listServices(ctx, clientset, instance.Namespace, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := convertServicesToEntries(services, instance, selector == "")
|
||||
if len(entries) == 0 && selector != "" {
|
||||
// Fallback: widen the search scope and filter manually.
|
||||
services, err = c.listServices(ctx, clientset, instance.Namespace, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = convertServicesToEntries(services, instance, true)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *EntryClient) collectIngressEntries(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
instance *entity.Instance,
|
||||
selector string,
|
||||
) ([]*entity.InstanceEntry, error) {
|
||||
ingresses, err := c.listIngresses(ctx, clientset, instance.Namespace, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := convertIngressesToEntries(ingresses, instance, selector == "")
|
||||
if len(entries) == 0 && selector != "" {
|
||||
ingresses, err = c.listIngresses(ctx, clientset, instance.Namespace, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = convertIngressesToEntries(ingresses, instance, true)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (c *EntryClient) listServices(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
namespace, selector string,
|
||||
) ([]corev1.Service, error) {
|
||||
listOptions := metav1.ListOptions{}
|
||||
if selector != "" {
|
||||
listOptions.LabelSelector = selector
|
||||
}
|
||||
services, err := clientset.CoreV1().
|
||||
Services(namespace).
|
||||
List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list services: %w", err)
|
||||
}
|
||||
return services.Items, nil
|
||||
}
|
||||
|
||||
func (c *EntryClient) listIngresses(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
namespace, selector string,
|
||||
) ([]networkingv1.Ingress, error) {
|
||||
listOptions := metav1.ListOptions{}
|
||||
if selector != "" {
|
||||
listOptions.LabelSelector = selector
|
||||
}
|
||||
ingresses, err := clientset.NetworkingV1().
|
||||
Ingresses(namespace).
|
||||
List(ctx, listOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list ingresses: %w", err)
|
||||
}
|
||||
return ingresses.Items, nil
|
||||
}
|
||||
|
||||
func convertServicesToEntries(services []corev1.Service, instance *entity.Instance, enforceMatch bool) []*entity.InstanceEntry {
|
||||
entries := make([]*entity.InstanceEntry, 0, len(services))
|
||||
for _, svc := range services {
|
||||
if enforceMatch && !resourceMatchesInstance(svc.ObjectMeta, instance) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, convertServiceToEntry(&svc))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func convertIngressesToEntries(ingresses []networkingv1.Ingress, instance *entity.Instance, enforceMatch bool) []*entity.InstanceEntry {
|
||||
entries := make([]*entity.InstanceEntry, 0, len(ingresses))
|
||||
for _, ing := range ingresses {
|
||||
if enforceMatch && !resourceMatchesInstance(ing.ObjectMeta, instance) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, convertIngressToEntry(&ing))
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (c *EntryClient) createClientset(cluster *entity.Cluster) (*kubernetes.Clientset, error) {
|
||||
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.GetKubeConfig()))
|
||||
if err != nil {
|
||||
config = &rest.Config{
|
||||
Host: cluster.Host,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
CAData: []byte(cluster.CAData),
|
||||
CertData: []byte(cluster.CertData),
|
||||
KeyData: []byte(cluster.KeyData),
|
||||
},
|
||||
BearerToken: cluster.Token,
|
||||
}
|
||||
}
|
||||
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
|
||||
}
|
||||
|
||||
return clientset, nil
|
||||
}
|
||||
|
||||
func convertServiceToEntry(svc *corev1.Service) *entity.InstanceEntry {
|
||||
clusterIP := svc.Spec.ClusterIP
|
||||
if clusterIP == corev1.ClusterIPNone {
|
||||
clusterIP = ""
|
||||
}
|
||||
|
||||
lbIngress := make([]string, 0, len(svc.Status.LoadBalancer.Ingress))
|
||||
for _, ing := range svc.Status.LoadBalancer.Ingress {
|
||||
if ing.IP != "" {
|
||||
lbIngress = append(lbIngress, ing.IP)
|
||||
}
|
||||
if ing.Hostname != "" {
|
||||
lbIngress = append(lbIngress, ing.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
ports := make([]entity.InstanceEntryPort, 0, len(svc.Spec.Ports))
|
||||
for _, port := range svc.Spec.Ports {
|
||||
ports = append(ports, entity.InstanceEntryPort{
|
||||
Name: port.Name,
|
||||
Protocol: string(port.Protocol),
|
||||
Port: port.Port,
|
||||
TargetPort: intOrStringToString(port.TargetPort),
|
||||
NodePort: port.NodePort,
|
||||
})
|
||||
}
|
||||
|
||||
return &entity.InstanceEntry{
|
||||
Kind: "Service",
|
||||
Name: svc.Name,
|
||||
Namespace: svc.Namespace,
|
||||
Type: string(svc.Spec.Type),
|
||||
ClusterIP: clusterIP,
|
||||
ExternalIPs: append([]string{}, svc.Spec.ExternalIPs...),
|
||||
LoadBalancerIngress: lbIngress,
|
||||
Ports: ports,
|
||||
}
|
||||
}
|
||||
|
||||
func convertIngressToEntry(ing *networkingv1.Ingress) *entity.InstanceEntry {
|
||||
lbIngress := make([]string, 0, len(ing.Status.LoadBalancer.Ingress))
|
||||
for _, addr := range ing.Status.LoadBalancer.Ingress {
|
||||
if addr.IP != "" {
|
||||
lbIngress = append(lbIngress, addr.IP)
|
||||
}
|
||||
if addr.Hostname != "" {
|
||||
lbIngress = append(lbIngress, addr.Hostname)
|
||||
}
|
||||
}
|
||||
|
||||
hosts := make([]entity.InstanceEntryHost, 0, len(ing.Spec.Rules))
|
||||
for _, rule := range ing.Spec.Rules {
|
||||
hostEntry := entity.InstanceEntryHost{
|
||||
Host: rule.Host,
|
||||
}
|
||||
if rule.HTTP != nil {
|
||||
paths := make([]entity.InstanceEntryPath, 0, len(rule.HTTP.Paths))
|
||||
for _, path := range rule.HTTP.Paths {
|
||||
name := ""
|
||||
port := ""
|
||||
if path.Backend.Service != nil {
|
||||
name = path.Backend.Service.Name
|
||||
port = serviceBackendPortString(path.Backend.Service.Port)
|
||||
}
|
||||
paths = append(paths, entity.InstanceEntryPath{
|
||||
Path: path.Path,
|
||||
ServiceName: name,
|
||||
ServicePort: port,
|
||||
})
|
||||
}
|
||||
hostEntry.Paths = paths
|
||||
}
|
||||
hosts = append(hosts, hostEntry)
|
||||
}
|
||||
|
||||
tlsEntries := make([]entity.InstanceEntryTLS, 0, len(ing.Spec.TLS))
|
||||
for _, tls := range ing.Spec.TLS {
|
||||
tlsEntries = append(tlsEntries, entity.InstanceEntryTLS{
|
||||
Hosts: append([]string{}, tls.Hosts...),
|
||||
SecretName: tls.SecretName,
|
||||
})
|
||||
}
|
||||
|
||||
entryType := "Ingress"
|
||||
if ing.Spec.IngressClassName != nil {
|
||||
entryType = *ing.Spec.IngressClassName
|
||||
}
|
||||
|
||||
return &entity.InstanceEntry{
|
||||
Kind: "Ingress",
|
||||
Name: ing.Name,
|
||||
Namespace: ing.Namespace,
|
||||
Type: entryType,
|
||||
LoadBalancerIngress: lbIngress,
|
||||
Hosts: hosts,
|
||||
TLS: tlsEntries,
|
||||
}
|
||||
}
|
||||
|
||||
func intOrStringToString(v intstr.IntOrString) string {
|
||||
if v.Type == intstr.String {
|
||||
return v.StrVal
|
||||
}
|
||||
return fmt.Sprintf("%d", v.IntValue())
|
||||
}
|
||||
|
||||
func serviceBackendPortString(port networkingv1.ServiceBackendPort) string {
|
||||
if port.Name != "" {
|
||||
return port.Name
|
||||
}
|
||||
if port.Number != 0 {
|
||||
return fmt.Sprintf("%d", port.Number)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resourceMatchesInstance(meta metav1.ObjectMeta, instance *entity.Instance) bool {
|
||||
if instance == nil {
|
||||
return false
|
||||
}
|
||||
labels := meta.GetLabels()
|
||||
if labels != nil {
|
||||
if labels["app.kubernetes.io/instance"] == instance.Name {
|
||||
return true
|
||||
}
|
||||
labelKeys := []string{"app", "app.kubernetes.io/name", "app.kubernetes.io/component", "release"}
|
||||
for _, key := range labelKeys {
|
||||
if labels[key] == instance.Name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
annotations := meta.GetAnnotations()
|
||||
if annotations != nil {
|
||||
if annotations["meta.helm.sh/release-name"] == instance.Name {
|
||||
if ns := annotations["meta.helm.sh/release-namespace"]; ns == "" || ns == instance.Namespace {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
name := meta.GetName()
|
||||
if name == instance.Name || strings.HasPrefix(name, instance.Name+"-") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
54
backend/internal/adapter/output/k8s/entry_client_test.go
Normal file
54
backend/internal/adapter/output/k8s/entry_client_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
func TestResourceMatchesInstance(t *testing.T) {
|
||||
instance := &entity.Instance{
|
||||
Name: "demo",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
meta metav1.ObjectMeta
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "matches by standard label",
|
||||
meta: metav1.ObjectMeta{Labels: map[string]string{
|
||||
"app.kubernetes.io/instance": "demo",
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches by helm annotations",
|
||||
meta: metav1.ObjectMeta{Annotations: map[string]string{
|
||||
"meta.helm.sh/release-name": "demo",
|
||||
"meta.helm.sh/release-namespace": "default",
|
||||
}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "matches by resource name prefix",
|
||||
meta: metav1.ObjectMeta{Name: "demo-nginx"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "does not match unrelated resource",
|
||||
meta: metav1.ObjectMeta{Name: "other"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
if got := resourceMatchesInstance(tc.meta, instance); got != tc.want {
|
||||
t.Fatalf("%s: expected %v, got %v", tc.name, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
370
backend/internal/adapter/output/k8s/metrics_client.go
Normal file
370
backend/internal/adapter/output/k8s/metrics_client.go
Normal file
@ -0,0 +1,370 @@
|
||||
package k8s
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes"
|
||||
"k8s.io/client-go/rest"
|
||||
"k8s.io/client-go/tools/clientcmd"
|
||||
metricsv "k8s.io/metrics/pkg/client/clientset/versioned"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// MetricsClient 实现从 Kubernetes 集群获取监控指标
|
||||
type MetricsClient struct {
|
||||
clusterRepo repository.ClusterRepository
|
||||
}
|
||||
|
||||
// NewMetricsClient 创建 MetricsClient
|
||||
func NewMetricsClient(clusterRepo repository.ClusterRepository) *MetricsClient {
|
||||
return &MetricsClient{
|
||||
clusterRepo: clusterRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterMetrics 获取集群监控指标
|
||||
func (c *MetricsClient) GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||
// 获取集群信息
|
||||
cluster, err := c.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
// 创建 Kubernetes 客户端
|
||||
clientset, metricsClient, err := c.createK8sClients(cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
// 获取节点列表
|
||||
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list nodes: %w", err)
|
||||
}
|
||||
|
||||
// 获取所有 Pods
|
||||
pods, err := clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list pods: %w", err)
|
||||
}
|
||||
|
||||
// 获取节点指标(CPU/内存使用情况)
|
||||
nodeMetrics, err := c.getNodeMetricsData(ctx, clientset, metricsClient, nodes.Items)
|
||||
if err != nil {
|
||||
// 如果无法获取 metrics,记录错误但继续
|
||||
fmt.Printf("Warning: failed to get node metrics: %v\n", err)
|
||||
}
|
||||
|
||||
// 计算集群级别汇总
|
||||
metrics := c.aggregateClusterMetrics(cluster, nodes.Items, pods.Items, nodeMetrics)
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// GetNodeMetrics 获取集群节点指标
|
||||
func (c *MetricsClient) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||
cluster, err := c.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
clientset, metricsClient, err := c.createK8sClients(cluster)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create k8s client: %w", err)
|
||||
}
|
||||
|
||||
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list nodes: %w", err)
|
||||
}
|
||||
|
||||
return c.getNodeMetricsData(ctx, clientset, metricsClient, nodes.Items)
|
||||
}
|
||||
|
||||
// createK8sClients 创建 Kubernetes 客户端
|
||||
func (c *MetricsClient) createK8sClients(cluster *entity.Cluster) (*kubernetes.Clientset, *metricsv.Clientset, error) {
|
||||
config, err := clientcmd.RESTConfigFromKubeConfig([]byte(cluster.GetKubeConfig()))
|
||||
if err != nil {
|
||||
// 如果无法从 kubeconfig 创建,尝试使用集群配置
|
||||
config = &rest.Config{
|
||||
Host: cluster.Host,
|
||||
TLSClientConfig: rest.TLSClientConfig{
|
||||
CAData: []byte(cluster.CAData),
|
||||
CertData: []byte(cluster.CertData),
|
||||
KeyData: []byte(cluster.KeyData),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
clientset, err := kubernetes.NewForConfig(config)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create clientset: %w", err)
|
||||
}
|
||||
|
||||
metricsClient, err := metricsv.NewForConfig(config)
|
||||
if err != nil {
|
||||
// Metrics API 可能不可用,返回 nil 但不报错
|
||||
return clientset, nil, nil
|
||||
}
|
||||
|
||||
return clientset, metricsClient, nil
|
||||
}
|
||||
|
||||
// getNodeMetricsData 获取节点详细指标
|
||||
func (c *MetricsClient) getNodeMetricsData(
|
||||
ctx context.Context,
|
||||
clientset *kubernetes.Clientset,
|
||||
metricsClient *metricsv.Clientset,
|
||||
nodes []corev1.Node,
|
||||
) ([]*entity.NodeMetrics, error) {
|
||||
result := make([]*entity.NodeMetrics, 0, len(nodes))
|
||||
|
||||
for _, node := range nodes {
|
||||
nodeMetric := &entity.NodeMetrics{
|
||||
NodeName: node.Name,
|
||||
Status: getNodeStatus(&node),
|
||||
Role: getNodeRole(&node),
|
||||
Age: getNodeAge(&node),
|
||||
OSImage: node.Status.NodeInfo.OSImage,
|
||||
KernelVersion: node.Status.NodeInfo.KernelVersion,
|
||||
ContainerRuntime: node.Status.NodeInfo.ContainerRuntimeVersion,
|
||||
KubeletVersion: node.Status.NodeInfo.KubeletVersion,
|
||||
}
|
||||
|
||||
// CPU
|
||||
cpuCapacity := node.Status.Capacity.Cpu()
|
||||
cpuAllocatable := node.Status.Allocatable.Cpu()
|
||||
nodeMetric.CPUCapacity = fmt.Sprintf("%.2f cores", float64(cpuCapacity.MilliValue())/1000.0)
|
||||
nodeMetric.CPUAllocatable = fmt.Sprintf("%.2f cores", float64(cpuAllocatable.MilliValue())/1000.0)
|
||||
|
||||
// Memory
|
||||
memCapacity := node.Status.Capacity.Memory()
|
||||
memAllocatable := node.Status.Allocatable.Memory()
|
||||
nodeMetric.MemoryCapacity = formatBytes(memCapacity.Value())
|
||||
nodeMetric.MemoryAllocatable = formatBytes(memAllocatable.Value())
|
||||
|
||||
// GPU (从 node allocatable 中查找)
|
||||
if gpu, ok := node.Status.Allocatable["nvidia.com/gpu"]; ok {
|
||||
nodeMetric.GPUCapacity = int(gpu.Value())
|
||||
// 尝试获取 GPU 类型
|
||||
if gpuType, ok := node.Labels["nvidia.com/gpu.product"]; ok {
|
||||
nodeMetric.GPUType = gpuType
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 Pod 数量
|
||||
pods, err := clientset.CoreV1().Pods("").List(ctx, metav1.ListOptions{
|
||||
FieldSelector: fmt.Sprintf("spec.nodeName=%s", node.Name),
|
||||
})
|
||||
if err == nil {
|
||||
nodeMetric.PodCount = len(pods.Items)
|
||||
}
|
||||
|
||||
// 如果有 metrics client,获取实时使用情况
|
||||
if metricsClient != nil {
|
||||
nodeMetricData, err := metricsClient.MetricsV1beta1().NodeMetricses().Get(ctx, node.Name, metav1.GetOptions{})
|
||||
if err == nil {
|
||||
// CPU 使用
|
||||
cpuUsage := nodeMetricData.Usage.Cpu()
|
||||
nodeMetric.CPUUsage = fmt.Sprintf("%.2f cores", float64(cpuUsage.MilliValue())/1000.0)
|
||||
if cpuAllocatable.MilliValue() > 0 {
|
||||
nodeMetric.CPUPercent = float64(cpuUsage.MilliValue()) / float64(cpuAllocatable.MilliValue()) * 100
|
||||
}
|
||||
|
||||
// Memory 使用
|
||||
memUsage := nodeMetricData.Usage.Memory()
|
||||
nodeMetric.MemoryUsage = formatBytes(memUsage.Value())
|
||||
if memAllocatable.Value() > 0 {
|
||||
nodeMetric.MemoryPercent = float64(memUsage.Value()) / float64(memAllocatable.Value()) * 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, nodeMetric)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// aggregateClusterMetrics 聚合集群级别指标
|
||||
func (c *MetricsClient) aggregateClusterMetrics(
|
||||
cluster *entity.Cluster,
|
||||
nodes []corev1.Node,
|
||||
pods []corev1.Pod,
|
||||
nodeMetrics []*entity.NodeMetrics,
|
||||
) *entity.ClusterMetrics {
|
||||
metrics := &entity.ClusterMetrics{
|
||||
ClusterID: cluster.ID,
|
||||
ClusterName: cluster.Name,
|
||||
Status: "healthy",
|
||||
NodeCount: len(nodes),
|
||||
PodCount: len(pods),
|
||||
LastCheck: time.Now(),
|
||||
Nodes: make([]entity.NodeMetrics, 0),
|
||||
}
|
||||
|
||||
// 汇总资源
|
||||
var totalCPU, totalMem, usedCPU, usedMem int64
|
||||
var totalGPU, usedGPU int
|
||||
healthyNodes := 0
|
||||
|
||||
// 单机最大值
|
||||
var maxNodeCPU, maxNodeMem int64
|
||||
var maxNodeGPU int
|
||||
var maxNodeCPUUsage, maxNodeMemUsage, maxNodeGPUUsage float64
|
||||
|
||||
for i, node := range nodes {
|
||||
// CPU
|
||||
cpuCap := node.Status.Capacity.Cpu()
|
||||
totalCPU += cpuCap.MilliValue()
|
||||
if cpuCap.MilliValue() > maxNodeCPU {
|
||||
maxNodeCPU = cpuCap.MilliValue()
|
||||
}
|
||||
|
||||
// Memory
|
||||
memCap := node.Status.Capacity.Memory()
|
||||
totalMem += memCap.Value()
|
||||
if memCap.Value() > maxNodeMem {
|
||||
maxNodeMem = memCap.Value()
|
||||
}
|
||||
|
||||
// GPU
|
||||
if gpu, ok := node.Status.Allocatable["nvidia.com/gpu"]; ok {
|
||||
gpuCount := int(gpu.Value())
|
||||
totalGPU += gpuCount
|
||||
if gpuCount > maxNodeGPU {
|
||||
maxNodeGPU = gpuCount
|
||||
}
|
||||
}
|
||||
|
||||
// Node status
|
||||
if getNodeStatus(&node) == "Ready" {
|
||||
healthyNodes++
|
||||
}
|
||||
|
||||
// 从 nodeMetrics 获取使用情况
|
||||
if i < len(nodeMetrics) && nodeMetrics[i] != nil {
|
||||
metrics.Nodes = append(metrics.Nodes, *nodeMetrics[i])
|
||||
|
||||
// 更新单机最大使用率
|
||||
if nodeMetrics[i].CPUPercent > maxNodeCPUUsage {
|
||||
maxNodeCPUUsage = nodeMetrics[i].CPUPercent
|
||||
}
|
||||
if nodeMetrics[i].MemoryPercent > maxNodeMemUsage {
|
||||
maxNodeMemUsage = nodeMetrics[i].MemoryPercent
|
||||
}
|
||||
if nodeMetrics[i].GPUPercent > maxNodeGPUUsage {
|
||||
maxNodeGPUUsage = nodeMetrics[i].GPUPercent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算集群 uptime(简化:使用最老节点的年龄)
|
||||
if len(nodes) > 0 {
|
||||
metrics.Uptime = getNodeAge(&nodes[0])
|
||||
}
|
||||
|
||||
// 格式化总资源
|
||||
metrics.TotalCPU = fmt.Sprintf("%.2f cores", float64(totalCPU)/1000.0)
|
||||
metrics.TotalMemory = formatBytes(totalMem)
|
||||
metrics.TotalGPU = totalGPU
|
||||
|
||||
// 格式化单机最大值
|
||||
metrics.MaxNodeCPU = fmt.Sprintf("%.2f cores", float64(maxNodeCPU)/1000.0)
|
||||
metrics.MaxNodeMemory = formatBytes(maxNodeMem)
|
||||
metrics.MaxNodeGPU = maxNodeGPU
|
||||
metrics.MaxNodeCPUUsage = maxNodeCPUUsage
|
||||
metrics.MaxNodeMemUsage = maxNodeMemUsage
|
||||
metrics.MaxNodeGPUUsage = maxNodeGPUUsage
|
||||
|
||||
// 使用情况(简化处理)
|
||||
if len(nodeMetrics) > 0 {
|
||||
for _, nm := range nodeMetrics {
|
||||
// 解析使用的 CPU 和内存
|
||||
// 这里简化处理,实际应该解析字符串
|
||||
usedCPU += int64(nm.CPUPercent * float64(totalCPU) / 100.0)
|
||||
usedMem += int64(nm.MemoryPercent * float64(totalMem) / 100.0)
|
||||
usedGPU += nm.GPUUsage
|
||||
}
|
||||
|
||||
if totalCPU > 0 {
|
||||
metrics.CPUUsage = float64(usedCPU) / float64(totalCPU) * 100
|
||||
}
|
||||
if totalMem > 0 {
|
||||
metrics.MemoryUsage = float64(usedMem) / float64(totalMem) * 100
|
||||
}
|
||||
if totalGPU > 0 {
|
||||
metrics.GPUUsage = float64(usedGPU) / float64(totalGPU) * 100
|
||||
}
|
||||
|
||||
metrics.UsedCPU = fmt.Sprintf("%.2f cores", float64(usedCPU)/1000.0)
|
||||
metrics.UsedMemory = formatBytes(usedMem)
|
||||
metrics.UsedGPU = usedGPU
|
||||
}
|
||||
|
||||
// 确定集群状态
|
||||
if healthyNodes == len(nodes) {
|
||||
metrics.Status = "healthy"
|
||||
} else if healthyNodes > 0 {
|
||||
metrics.Status = "warning"
|
||||
} else {
|
||||
metrics.Status = "error"
|
||||
}
|
||||
|
||||
return metrics
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func getNodeStatus(node *corev1.Node) string {
|
||||
for _, condition := range node.Status.Conditions {
|
||||
if condition.Type == corev1.NodeReady {
|
||||
if condition.Status == corev1.ConditionTrue {
|
||||
return "Ready"
|
||||
}
|
||||
return "NotReady"
|
||||
}
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
func getNodeRole(node *corev1.Node) string {
|
||||
if _, ok := node.Labels["node-role.kubernetes.io/control-plane"]; ok {
|
||||
return "control-plane"
|
||||
}
|
||||
if _, ok := node.Labels["node-role.kubernetes.io/master"]; ok {
|
||||
return "control-plane"
|
||||
}
|
||||
return "worker"
|
||||
}
|
||||
|
||||
func getNodeAge(node *corev1.Node) string {
|
||||
age := time.Since(node.CreationTimestamp.Time)
|
||||
days := int(age.Hours() / 24)
|
||||
hours := int(age.Hours()) % 24
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
|
||||
func formatBytes(bytes int64) string {
|
||||
const unit = 1024
|
||||
if bytes < unit {
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := bytes / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(bytes)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
284
backend/internal/adapter/output/oci/mock/oci_client_mock.go
Normal file
284
backend/internal/adapter/output/oci/mock/oci_client_mock.go
Normal file
@ -0,0 +1,284 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// OCIClientMock OCI Registry 客户端 Mock 实现
|
||||
type OCIClientMock struct {
|
||||
// Mock 数据存储
|
||||
repositories map[string][]string // registryID -> []repositoryName
|
||||
artifacts map[string]map[string][]*entity.Artifact // registryID -> repository -> []artifact
|
||||
}
|
||||
|
||||
// NewOCIClientMock 创建 Mock 实现
|
||||
func NewOCIClientMock() repository.OCIClient {
|
||||
mock := &OCIClientMock{
|
||||
repositories: make(map[string][]string),
|
||||
artifacts: make(map[string]map[string][]*entity.Artifact),
|
||||
}
|
||||
|
||||
// 初始化一些测试数据
|
||||
mock.initMockData()
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) initMockData() {
|
||||
// Note: This method intentionally left empty
|
||||
// Mock data will be generated dynamically per registry to support any registry ID
|
||||
}
|
||||
|
||||
// initArtifactsForRegistry initializes mock artifacts for a given registry ID
|
||||
func (c *OCIClientMock) initArtifactsForRegistry(registryID string) {
|
||||
c.artifacts[registryID] = make(map[string][]*entity.Artifact)
|
||||
|
||||
// vllm-serve artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/vllm-serve"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.1.0",
|
||||
Digest: "sha256:abc123def456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 12345678,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "vllm-serve",
|
||||
"org.opencontainers.image.version": "0.1.0",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/vllm-serve",
|
||||
Tag: "0.2.0",
|
||||
Digest: "sha256:xyz789uvw012",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 13456789,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "vllm-serve",
|
||||
"org.opencontainers.image.version": "0.2.0",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// nginx artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/nginx"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/nginx",
|
||||
Tag: "1.0.0",
|
||||
Digest: "sha256:nginx123456",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 5678901,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "nginx",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-48 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// redis artifacts (OCI 格式的 Helm Chart)
|
||||
c.artifacts[registryID]["charts/redis"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "charts/redis",
|
||||
Tag: "6.2.0",
|
||||
Digest: "sha256:redis789abc",
|
||||
Type: entity.ArtifactTypeChart,
|
||||
Size: 8901234,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
ConfigType: "application/vnd.cncf.helm.config.v1+json", // Helm Chart 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "redis",
|
||||
"org.opencontainers.image.version": "6.2.0",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour),
|
||||
},
|
||||
}
|
||||
|
||||
// alpine artifacts (Docker Image)
|
||||
c.artifacts[registryID]["library/alpine"] = []*entity.Artifact{
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "3.18",
|
||||
Digest: "sha256:alpine123",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2345678,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "alpine",
|
||||
"org.opencontainers.image.version": "3.18",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-96 * time.Hour),
|
||||
},
|
||||
{
|
||||
RegistryID: registryID,
|
||||
Repository: "library/alpine",
|
||||
Tag: "latest",
|
||||
Digest: "sha256:alpine456",
|
||||
Type: entity.ArtifactTypeImage,
|
||||
Size: 2456789,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
ConfigType: "application/vnd.docker.container.image.v1+json", // Docker Image 的 config type
|
||||
Annotations: map[string]string{
|
||||
"org.opencontainers.image.title": "alpine",
|
||||
},
|
||||
CreatedAt: time.Now().Add(-24 * time.Hour),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
||||
// Check if we have cached data for this registry
|
||||
repos, exists := c.repositories[registry.ID]
|
||||
if !exists {
|
||||
// Generate mock data dynamically for any registry
|
||||
repos = []string{
|
||||
"charts/vllm-serve",
|
||||
"charts/nginx",
|
||||
"charts/redis",
|
||||
"library/alpine",
|
||||
}
|
||||
c.repositories[registry.ID] = repos
|
||||
|
||||
// Also initialize artifacts for this registry
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
}
|
||||
return repos, nil
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
regArtifacts, exists := c.artifacts[registry.ID]
|
||||
if !exists {
|
||||
// Initialize artifacts for this registry if not exists
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
regArtifacts = c.artifacts[registry.ID]
|
||||
}
|
||||
|
||||
artifacts, exists := regArtifacts[repository]
|
||||
if !exists {
|
||||
return []*entity.Artifact{}, nil
|
||||
}
|
||||
|
||||
// 应用 mediaType 过滤
|
||||
if mediaTypeFilter == "" || mediaTypeFilter == "all" {
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
filtered := make([]*entity.Artifact, 0)
|
||||
filter := strings.ToLower(strings.TrimSpace(mediaTypeFilter))
|
||||
|
||||
for _, artifact := range artifacts {
|
||||
switch filter {
|
||||
case "chart":
|
||||
if artifact.Type == entity.ArtifactTypeChart {
|
||||
filtered = append(filtered, artifact)
|
||||
}
|
||||
case "image":
|
||||
if artifact.Type == entity.ArtifactTypeImage {
|
||||
filtered = append(filtered, artifact)
|
||||
}
|
||||
case "other":
|
||||
if artifact.Type == entity.ArtifactTypeOther {
|
||||
filtered = append(filtered, artifact)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error) {
|
||||
regArtifacts, exists := c.artifacts[registry.ID]
|
||||
if !exists {
|
||||
// Initialize artifacts for this registry if not exists
|
||||
c.initArtifactsForRegistry(registry.ID)
|
||||
regArtifacts = c.artifacts[registry.ID]
|
||||
}
|
||||
|
||||
artifacts, exists := regArtifacts[repository]
|
||||
if !exists {
|
||||
return nil, entity.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
// 根据 tag 或 digest 查找
|
||||
for _, artifact := range artifacts {
|
||||
if artifact.Tag == reference || artifact.Digest == reference {
|
||||
return artifact, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, entity.ErrArtifactNotFound
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) GetValuesSchema(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 schema
|
||||
mockSchema := `{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"replicaCount": {
|
||||
"type": "integer",
|
||||
"default": 1
|
||||
},
|
||||
"image": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repository": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
return mockSchema, 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
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error {
|
||||
// Mock 实现,不实际上传
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OCIClientMock) CheckHealth(ctx context.Context, registry *entity.Registry) error {
|
||||
// Mock 实现,总是返回健康
|
||||
return nil
|
||||
}
|
||||
|
||||
468
backend/internal/adapter/output/oci/real/oci_client.go
Normal file
468
backend/internal/adapter/output/oci/real/oci_client.go
Normal file
@ -0,0 +1,468 @@
|
||||
package real
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/opencontainers/go-digest"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/registry/remote"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
)
|
||||
|
||||
// OCIClient 真实的 OCI 客户端实现(使用 ORAS)
|
||||
type OCIClient struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewOCIClient 创建真实的 OCI 客户端
|
||||
func NewOCIClient() repository.OCIClient {
|
||||
return &OCIClient{
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// getRegistry 创建 ORAS Registry 客户端
|
||||
func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error) {
|
||||
// 解析 Registry URL
|
||||
registryURL := strings.TrimPrefix(reg.URL, "https://")
|
||||
registryURL = strings.TrimPrefix(registryURL, "http://")
|
||||
|
||||
registry, err := remote.NewRegistry(registryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create registry client: %w", err)
|
||||
}
|
||||
|
||||
// 设置认证
|
||||
if reg.Username != "" && reg.Password != "" {
|
||||
registry.Client = &auth.Client{
|
||||
Client: c.httpClient,
|
||||
Credential: auth.StaticCredential(registryURL, auth.Credential{
|
||||
Username: reg.Username,
|
||||
Password: reg.Password,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 PlainHTTP(如果是 insecure)
|
||||
registry.PlainHTTP = reg.Insecure
|
||||
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
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
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list repositories: %w", err)
|
||||
}
|
||||
|
||||
return repositories, nil
|
||||
}
|
||||
|
||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||
func (c *OCIClient) ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repo, err := reg.Repository(ctx, repository)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository: %w", err)
|
||||
}
|
||||
|
||||
artifacts := make([]*entity.Artifact, 0)
|
||||
|
||||
err = repo.Tags(ctx, "", func(tags []string) error {
|
||||
for _, tag := range tags {
|
||||
// 获取 manifest 以获取更多信息
|
||||
desc, err := repo.Resolve(ctx, tag)
|
||||
if err != nil {
|
||||
// 跳过无法解析的 tag
|
||||
continue
|
||||
}
|
||||
|
||||
artifact := &entity.Artifact{
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
Digest: desc.Digest.String(),
|
||||
MediaType: desc.MediaType,
|
||||
Size: desc.Size,
|
||||
}
|
||||
|
||||
// 尝试获取 config.mediaType 以更准确判断类型
|
||||
if manifestBytes, err := repo.Fetch(ctx, desc); err == nil {
|
||||
defer manifestBytes.Close()
|
||||
if manifestData, err := io.ReadAll(manifestBytes); err == nil {
|
||||
var manifest map[string]interface{}
|
||||
if err := json.Unmarshal(manifestData, &manifest); err == nil {
|
||||
// 获取 config.mediaType
|
||||
if config, ok := manifest["config"].(map[string]interface{}); ok {
|
||||
if configMediaType, ok := config["mediaType"].(string); ok {
|
||||
artifact.ConfigType = configMediaType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用智能类型判断(综合多种信息)
|
||||
artifact.DetermineType()
|
||||
|
||||
// 应用 mediaType 过滤
|
||||
if c.shouldIncludeArtifact(artifact, mediaTypeFilter) {
|
||||
artifacts = append(artifacts, artifact)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list artifacts: %w", err)
|
||||
}
|
||||
|
||||
return artifacts, nil
|
||||
}
|
||||
|
||||
// shouldIncludeArtifact 判断是否应该包含该 artifact
|
||||
func (c *OCIClient) shouldIncludeArtifact(artifact *entity.Artifact, filter string) bool {
|
||||
// 默认或 "all" 返回所有
|
||||
if filter == "" || filter == "all" {
|
||||
return true
|
||||
}
|
||||
|
||||
filter = strings.ToLower(strings.TrimSpace(filter))
|
||||
|
||||
switch filter {
|
||||
case "chart":
|
||||
// 只返回 Helm Charts
|
||||
return artifact.Type == entity.ArtifactTypeChart
|
||||
case "image":
|
||||
// 返回 Docker 或 OCI images
|
||||
return artifact.Type == entity.ArtifactTypeImage
|
||||
case "other":
|
||||
// 返回其他类型
|
||||
return artifact.Type == entity.ArtifactTypeOther
|
||||
default:
|
||||
// 未知的 filter,返回所有
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// GetArtifact 获取指定 artifact 的详细信息
|
||||
func (c *OCIClient) GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error) {
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
repo, err := reg.Repository(ctx, repository)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get repository: %w", err)
|
||||
}
|
||||
|
||||
// 解析 reference
|
||||
desc, err := repo.Resolve(ctx, reference)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve artifact: %w", err)
|
||||
}
|
||||
|
||||
// 获取 manifest
|
||||
manifestBytes, err := repo.Fetch(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch manifest: %w", err)
|
||||
}
|
||||
defer manifestBytes.Close()
|
||||
|
||||
manifestData, err := io.ReadAll(manifestBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read manifest: %w", err)
|
||||
}
|
||||
|
||||
// 解析 manifest 获取配置信息
|
||||
var manifest map[string]interface{}
|
||||
if err := json.Unmarshal(manifestData, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal manifest: %w", err)
|
||||
}
|
||||
|
||||
artifact := &entity.Artifact{
|
||||
Repository: repository,
|
||||
Tag: reference,
|
||||
Digest: desc.Digest.String(),
|
||||
MediaType: desc.MediaType,
|
||||
Size: desc.Size,
|
||||
Annotations: make(map[string]string),
|
||||
}
|
||||
|
||||
// 获取 config.mediaType 和 annotations
|
||||
if config, ok := manifest["config"].(map[string]interface{}); ok {
|
||||
// 获取 config.mediaType(用于准确的类型判断)
|
||||
if configMediaType, ok := config["mediaType"].(string); ok {
|
||||
artifact.ConfigType = configMediaType
|
||||
}
|
||||
|
||||
// 获取 annotations
|
||||
if annotations, ok := config["annotations"].(map[string]interface{}); ok {
|
||||
for k, v := range annotations {
|
||||
if str, ok := v.(string); ok {
|
||||
artifact.Annotations[k] = str
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用智能类型判断(综合 ConfigType, Annotations, Repository 名称等)
|
||||
artifact.DetermineType()
|
||||
|
||||
return artifact, nil
|
||||
}
|
||||
|
||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||
func (c *OCIClient) GetValuesSchema(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)
|
||||
}
|
||||
|
||||
// 优先查找是否存在独立的 values schema layer(一些 registry 会将 values.schema.json 作为单独的 layer 存储)
|
||||
var valuesSchemaLayer *ocispec.Descriptor
|
||||
for i := range manifest.Layers {
|
||||
layer := manifest.Layers[i]
|
||||
mediaType := strings.ToLower(layer.MediaType)
|
||||
|
||||
if strings.Contains(mediaType, "helm.values.schema") ||
|
||||
strings.Contains(mediaType, "values.schema") {
|
||||
valuesSchemaLayer = &manifest.Layers[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果存在独立的 values schema layer,直接返回
|
||||
if valuesSchemaLayer != nil {
|
||||
reader, err := repo.Fetch(ctx, *valuesSchemaLayer)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to fetch values schema layer: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read values schema layer: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return "", entity.ErrValuesSchemaNotFound
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// 回退:查找 Helm Chart layer(tar+gzip 包含 chart 内容)并从中读取 values.schema.json
|
||||
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.ErrValuesSchemaNotFound
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if strings.HasSuffix(header.Name, "values.schema.json") {
|
||||
data, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read values.schema.json: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", entity.ErrValuesSchemaNotFound
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", entity.ErrValuesSchemaNotFound
|
||||
}
|
||||
|
||||
// PullArtifact 下载 artifact 到本地
|
||||
func (c *OCIClient) PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath 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
|
||||
desc, err := repo.Resolve(ctx, reference)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve artifact: %w", err)
|
||||
}
|
||||
|
||||
// 获取 manifest 内容
|
||||
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)
|
||||
}
|
||||
|
||||
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 = &layer
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if chartLayer == nil {
|
||||
return fmt.Errorf("helm chart layer not found in manifest")
|
||||
}
|
||||
|
||||
content, err := repo.Fetch(ctx, *chartLayer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch chart layer: %w", err)
|
||||
}
|
||||
defer content.Close()
|
||||
|
||||
// 确保目标目录存在
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
// 写入文件
|
||||
file, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(file, content); err != nil {
|
||||
return fmt.Errorf("failed to write artifact: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PushArtifact 推送 artifact 到 Registry
|
||||
func (c *OCIClient) PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error {
|
||||
// 这是一个简化实现
|
||||
// 实际应该实现完整的 OCI artifact push 流程
|
||||
return fmt.Errorf("push artifact not fully implemented yet")
|
||||
}
|
||||
|
||||
// CheckHealth 检查 Registry 健康状态
|
||||
func (c *OCIClient) CheckHealth(ctx context.Context, registry *entity.Registry) error {
|
||||
reg, err := c.getRegistry(registry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 尝试 ping registry
|
||||
err = reg.Ping(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("registry health check failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,174 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// ClusterRepositoryMock 集群仓储 Mock 实现(内存存储,支持加密)
|
||||
type ClusterRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
clusters map[string]*entity.Cluster // key: cluster ID
|
||||
encryptor crypto.Encryptor // 加密器
|
||||
}
|
||||
|
||||
// NewClusterRepositoryMock 创建 Mock 实现
|
||||
func NewClusterRepositoryMock(encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||
return &ClusterRepositoryMock{
|
||||
clusters: make(map[string]*entity.Cluster),
|
||||
encryptor: encryptor,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 检查名称是否已存在
|
||||
for _, c := range r.clusters {
|
||||
if c.Name == cluster.Name {
|
||||
return entity.ErrClusterExists
|
||||
}
|
||||
}
|
||||
|
||||
// Mock 模式:如果没有提供认证信息,自动填充默认的 Mock 证书
|
||||
if (cluster.CertData == "" || cluster.KeyData == "") && cluster.Token == "" {
|
||||
cluster.CAData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ0EgQ2VydGlmaWNhdGUKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ=="
|
||||
cluster.CertData = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1vY2sgQ2xpZW50IENlcnRpZmljYXRlCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0="
|
||||
cluster.KeyData = "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNb2NrIFByaXZhdGUgS2V5Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t"
|
||||
}
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedCluster := r.encryptCluster(cluster)
|
||||
r.clusters[cluster.ID] = encryptedCluster
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
cluster, exists := r.clusters[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptCluster(cluster), nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, cluster := range r.clusters {
|
||||
if cluster.Name == name {
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptCluster(cluster), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.clusters[cluster.ID]; !exists {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedCluster := r.encryptCluster(cluster)
|
||||
r.clusters[cluster.ID] = encryptedCluster
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ClusterRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.clusters[id]; !exists {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
delete(r.clusters, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// encryptCluster 加密 Cluster 的敏感数据
|
||||
func (r *ClusterRepositoryMock) encryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||
encrypted := *cluster // 复制
|
||||
|
||||
// 加密证书数据
|
||||
if cluster.CAData != "" && !crypto.IsEncrypted(cluster.CAData) {
|
||||
if encryptedData, err := r.encryptor.Encrypt(cluster.CAData); err == nil {
|
||||
encrypted.CAData = encryptedData
|
||||
}
|
||||
}
|
||||
if cluster.CertData != "" && !crypto.IsEncrypted(cluster.CertData) {
|
||||
if encryptedData, err := r.encryptor.Encrypt(cluster.CertData); err == nil {
|
||||
encrypted.CertData = encryptedData
|
||||
}
|
||||
}
|
||||
if cluster.KeyData != "" && !crypto.IsEncrypted(cluster.KeyData) {
|
||||
if encryptedData, err := r.encryptor.Encrypt(cluster.KeyData); err == nil {
|
||||
encrypted.KeyData = encryptedData
|
||||
}
|
||||
}
|
||||
if cluster.Token != "" && !crypto.IsEncrypted(cluster.Token) {
|
||||
if encryptedData, err := r.encryptor.Encrypt(cluster.Token); err == nil {
|
||||
encrypted.Token = encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
return &encrypted
|
||||
}
|
||||
|
||||
// decryptCluster 解密 Cluster 的敏感数据
|
||||
func (r *ClusterRepositoryMock) decryptCluster(cluster *entity.Cluster) *entity.Cluster {
|
||||
decrypted := *cluster // 复制
|
||||
|
||||
// 解密证书数据
|
||||
if cluster.CAData != "" && crypto.IsEncrypted(cluster.CAData) {
|
||||
if decryptedData, err := r.encryptor.Decrypt(cluster.CAData); err == nil {
|
||||
decrypted.CAData = decryptedData
|
||||
}
|
||||
}
|
||||
if cluster.CertData != "" && crypto.IsEncrypted(cluster.CertData) {
|
||||
if decryptedData, err := r.encryptor.Decrypt(cluster.CertData); err == nil {
|
||||
decrypted.CertData = decryptedData
|
||||
}
|
||||
}
|
||||
if cluster.KeyData != "" && crypto.IsEncrypted(cluster.KeyData) {
|
||||
if decryptedData, err := r.encryptor.Decrypt(cluster.KeyData); err == nil {
|
||||
decrypted.KeyData = decryptedData
|
||||
}
|
||||
}
|
||||
if cluster.Token != "" && crypto.IsEncrypted(cluster.Token) {
|
||||
if decryptedData, err := r.encryptor.Decrypt(cluster.Token); err == nil {
|
||||
decrypted.Token = decryptedData
|
||||
}
|
||||
}
|
||||
|
||||
return &decrypted
|
||||
}
|
||||
|
||||
@ -0,0 +1,113 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// InstanceRepositoryMock 实例仓储 Mock 实现(内存存储)
|
||||
type InstanceRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
instances map[string]*entity.Instance // key: instance ID
|
||||
}
|
||||
|
||||
// NewInstanceRepositoryMock 创建 Mock 实现
|
||||
func NewInstanceRepositoryMock() repository.InstanceRepository {
|
||||
return &InstanceRepositoryMock{
|
||||
instances: make(map[string]*entity.Instance),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) Create(ctx context.Context, instance *entity.Instance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 检查同一集群中名称是否已存在
|
||||
for _, inst := range r.instances {
|
||||
if inst.ClusterID == instance.ClusterID && inst.Name == instance.Name {
|
||||
return entity.ErrInstanceExists
|
||||
}
|
||||
}
|
||||
|
||||
r.instances[instance.ID] = instance
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
instance, exists := r.instances[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, instance := range r.instances {
|
||||
if instance.ClusterID == clusterID && instance.Name == name {
|
||||
return instance, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) Update(ctx context.Context, instance *entity.Instance) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.instances[instance.ID]; !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
r.instances[instance.ID] = instance
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.instances[id]; !exists {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
delete(r.instances, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *InstanceRepositoryMock) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
instances := make([]*entity.Instance, 0)
|
||||
for _, instance := range r.instances {
|
||||
if instance.ClusterID == clusterID {
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// RegistryRepositoryMock Registry 仓储 Mock 实现(内存存储,支持加密)
|
||||
type RegistryRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
registries map[string]*entity.Registry // key: registry ID
|
||||
encryptor crypto.Encryptor // 加密器
|
||||
}
|
||||
|
||||
// NewRegistryRepositoryMock 创建 Mock 实现
|
||||
func NewRegistryRepositoryMock(encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||
return &RegistryRepositoryMock{
|
||||
registries: make(map[string]*entity.Registry),
|
||||
encryptor: encryptor,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) Create(ctx context.Context, registry *entity.Registry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 检查名称是否已存在
|
||||
for _, reg := range r.registries {
|
||||
if reg.Name == registry.Name {
|
||||
return entity.ErrRegistryExists
|
||||
}
|
||||
}
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedRegistry := r.encryptRegistry(registry)
|
||||
r.registries[registry.ID] = encryptedRegistry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
registry, exists := r.registries[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptRegistry(registry), nil
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, registry := range r.registries {
|
||||
if registry.Name == name {
|
||||
// 解密敏感数据后返回
|
||||
return r.decryptRegistry(registry), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) Update(ctx context.Context, registry *entity.Registry) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.registries[registry.ID]; !exists {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
// 加密敏感数据后存储
|
||||
encryptedRegistry := r.encryptRegistry(registry)
|
||||
r.registries[registry.ID] = encryptedRegistry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.registries[id]; !exists {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
delete(r.registries, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RegistryRepositoryMock) List(ctx context.Context) ([]*entity.Registry, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
registries := make([]*entity.Registry, 0, len(r.registries))
|
||||
for _, registry := range r.registries {
|
||||
// 解密敏感数据后返回
|
||||
registries = append(registries, r.decryptRegistry(registry))
|
||||
}
|
||||
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
// encryptRegistry 加密 Registry 的敏感数据
|
||||
func (r *RegistryRepositoryMock) encryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||
encrypted := *registry // 复制
|
||||
|
||||
// 加密密码
|
||||
if registry.Password != "" && !crypto.IsEncrypted(registry.Password) {
|
||||
if encryptedPassword, err := r.encryptor.Encrypt(registry.Password); err == nil {
|
||||
encrypted.Password = encryptedPassword
|
||||
}
|
||||
}
|
||||
|
||||
return &encrypted
|
||||
}
|
||||
|
||||
// decryptRegistry 解密 Registry 的敏感数据
|
||||
func (r *RegistryRepositoryMock) decryptRegistry(registry *entity.Registry) *entity.Registry {
|
||||
decrypted := *registry // 复制
|
||||
|
||||
// 解密密码
|
||||
if registry.Password != "" && crypto.IsEncrypted(registry.Password) {
|
||||
if decryptedPassword, err := r.encryptor.Decrypt(registry.Password); err == nil {
|
||||
decrypted.Password = decryptedPassword
|
||||
}
|
||||
}
|
||||
|
||||
return &decrypted
|
||||
}
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// UserRepositoryMock 用户仓储 Mock 实现(内存存储)
|
||||
type UserRepositoryMock struct {
|
||||
mu sync.RWMutex
|
||||
users map[string]*entity.User // key: user ID
|
||||
}
|
||||
|
||||
// NewUserRepositoryMock 创建 Mock 实现
|
||||
func NewUserRepositoryMock() repository.UserRepository {
|
||||
return &UserRepositoryMock{
|
||||
users: make(map[string]*entity.User),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) Create(ctx context.Context, user *entity.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// 检查是否已存在
|
||||
for _, u := range r.users {
|
||||
if u.Username == user.Username {
|
||||
return entity.ErrUserExists
|
||||
}
|
||||
}
|
||||
|
||||
r.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) GetByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
user, exists := r.users[id]
|
||||
if !exists {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, user := range r.users {
|
||||
if user.Username == username {
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) Update(ctx context.Context, user *entity.User) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.users[user.ID]; !exists {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
r.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepositoryMock) Delete(ctx context.Context, id string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, exists := r.users[id]; !exists {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
delete(r.users, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -0,0 +1,337 @@
|
||||
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"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// ClusterRepository PostgreSQL 集群仓储实现
|
||||
type ClusterRepository struct {
|
||||
db *DB
|
||||
encryptor crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewClusterRepository 创建 PostgreSQL 集群仓储
|
||||
func NewClusterRepository(db *DB, encryptor crypto.Encryptor) repository.ClusterRepository {
|
||||
return &ClusterRepository{
|
||||
db: db,
|
||||
encryptor: encryptor,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建集群
|
||||
func (r *ClusterRepository) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||
if cluster.ID == "" {
|
||||
cluster.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 加密敏感数据
|
||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||
}
|
||||
|
||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
cluster.ID,
|
||||
cluster.Name,
|
||||
cluster.Host,
|
||||
encryptedCAData,
|
||||
encryptedCertData,
|
||||
encryptedKeyData,
|
||||
encryptedToken,
|
||||
cluster.Description,
|
||||
cluster.CreatedAt,
|
||||
cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create cluster: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM clusters
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&cluster.ID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
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)
|
||||
}
|
||||
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM clusters
|
||||
WHERE name = $1
|
||||
`
|
||||
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||
&cluster.ID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
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)
|
||||
}
|
||||
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// Update 更新集群
|
||||
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||
cluster.UpdatedAt = time.Now()
|
||||
|
||||
// 加密敏感数据
|
||||
encryptedCAData, err := r.encryptor.Encrypt(cluster.CAData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt CA data: %w", err)
|
||||
}
|
||||
|
||||
encryptedCertData, err := r.encryptor.Encrypt(cluster.CertData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt cert data: %w", err)
|
||||
}
|
||||
|
||||
encryptedKeyData, err := r.encryptor.Encrypt(cluster.KeyData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key data: %w", err)
|
||||
}
|
||||
|
||||
encryptedToken, err := r.encryptor.Encrypt(cluster.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt token: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
cluster.Name,
|
||||
cluster.Host,
|
||||
encryptedCAData,
|
||||
encryptedCertData,
|
||||
encryptedKeyData,
|
||||
encryptedToken,
|
||||
cluster.Description,
|
||||
cluster.UpdatedAt,
|
||||
cluster.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update cluster: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除集群
|
||||
func (r *ClusterRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM clusters WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete cluster: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM clusters
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clusters := make([]*entity.Cluster, 0)
|
||||
for rows.Next() {
|
||||
cluster := &entity.Cluster{}
|
||||
var encryptedCAData, encryptedCertData, encryptedKeyData, encryptedToken string
|
||||
|
||||
err := rows.Scan(
|
||||
&cluster.ID,
|
||||
&cluster.Name,
|
||||
&cluster.Host,
|
||||
&encryptedCAData,
|
||||
&encryptedCertData,
|
||||
&encryptedKeyData,
|
||||
&encryptedToken,
|
||||
&cluster.Description,
|
||||
&cluster.CreatedAt,
|
||||
&cluster.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan cluster: %w", err)
|
||||
}
|
||||
|
||||
// 解密敏感数据
|
||||
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)
|
||||
}
|
||||
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return clusters, nil
|
||||
}
|
||||
|
||||
135
backend/internal/adapter/output/persistence/postgres/db.go
Normal file
135
backend/internal/adapter/output/persistence/postgres/db.go
Normal file
@ -0,0 +1,135 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// DB 数据库连接包装器
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// NewDB 创建新的数据库连接
|
||||
func NewDB(connString string) (*DB, error) {
|
||||
if connString == "" {
|
||||
return nil, fmt.Errorf("database connection string cannot be empty")
|
||||
}
|
||||
|
||||
conn, err := sql.Open("postgres", connString)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
conn.SetMaxOpenConns(25)
|
||||
conn.SetMaxIdleConns(5)
|
||||
conn.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// 测试连接
|
||||
if err := conn.Ping(); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{conn: conn}, nil
|
||||
}
|
||||
|
||||
// Close 关闭数据库连接
|
||||
func (db *DB) Close() error {
|
||||
if db.conn != nil {
|
||||
return db.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConn 获取底层连接(用于事务等高级操作)
|
||||
func (db *DB) GetConn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// InitSchema 初始化数据库 schema
|
||||
func (db *DB) InitSchema() error {
|
||||
schema := `
|
||||
-- Users 表
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
|
||||
-- Clusters 表
|
||||
CREATE TABLE IF NOT EXISTS clusters (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
host TEXT NOT NULL,
|
||||
ca_data TEXT,
|
||||
cert_data TEXT,
|
||||
key_data TEXT,
|
||||
token TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
|
||||
|
||||
-- Registries 表
|
||||
CREATE TABLE IF NOT EXISTS registries (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
description TEXT,
|
||||
username VARCHAR(255),
|
||||
password TEXT,
|
||||
insecure 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_registries_name ON registries(name);
|
||||
|
||||
-- Instances 表
|
||||
CREATE TABLE IF NOT EXISTS instances (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
cluster_id VARCHAR(36) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
namespace VARCHAR(255) NOT NULL,
|
||||
registry_id VARCHAR(36) NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
chart VARCHAR(255) NOT NULL,
|
||||
version VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
values JSONB,
|
||||
values_yaml TEXT,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
status_reason TEXT,
|
||||
last_operation VARCHAR(50),
|
||||
last_error TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT fk_cluster FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_registry FOREIGN KEY (registry_id) REFERENCES registries(id) ON DELETE CASCADE,
|
||||
CONSTRAINT unique_cluster_name UNIQUE (cluster_id, name, namespace)
|
||||
);
|
||||
|
||||
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);
|
||||
`
|
||||
|
||||
_, err := db.conn.Exec(schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize schema: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,433 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// InstanceRepository PostgreSQL 实例仓储实现
|
||||
type InstanceRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewInstanceRepository 创建 PostgreSQL 实例仓储
|
||||
func NewInstanceRepository(db *DB) repository.InstanceRepository {
|
||||
return &InstanceRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建实例
|
||||
func (r *InstanceRepository) Create(ctx context.Context, instance *entity.Instance) error {
|
||||
if instance.ID == "" {
|
||||
instance.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 将 Values 转换为 JSON
|
||||
valuesJSON, err := json.Marshal(instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal values: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO instances (id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
instance.ID,
|
||||
instance.ClusterID,
|
||||
instance.Name,
|
||||
instance.Namespace,
|
||||
instance.RegistryID,
|
||||
instance.Repository,
|
||||
instance.Chart,
|
||||
instance.Version,
|
||||
instance.Description,
|
||||
valuesJSON,
|
||||
instance.ValuesYAML,
|
||||
instance.Status,
|
||||
instance.StatusReason,
|
||||
instance.LastOperation,
|
||||
instance.LastError,
|
||||
instance.Revision,
|
||||
instance.CreatedAt,
|
||||
instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create instance: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取实例
|
||||
func (r *InstanceRepository) GetByID(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||
func (r *InstanceRepository) GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE cluster_id = $1 AND name = $2
|
||||
`
|
||||
|
||||
instance := &entity.Instance{}
|
||||
var (
|
||||
valuesJSON []byte
|
||||
statusReason sql.NullString
|
||||
lastOperation sql.NullString
|
||||
lastError sql.NullString
|
||||
)
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, clusterID, name).Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// Update 更新实例
|
||||
func (r *InstanceRepository) Update(ctx context.Context, instance *entity.Instance) error {
|
||||
instance.UpdatedAt = time.Now()
|
||||
|
||||
// 将 Values 转换为 JSON
|
||||
valuesJSON, err := json.Marshal(instance.Values)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal values: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE instances
|
||||
SET cluster_id = $1, name = $2, namespace = $3, registry_id = $4, repository = $5,
|
||||
chart = $6, version = $7, description = $8, values = $9, values_yaml = $10,
|
||||
status = $11, status_reason = $12, last_operation = $13, last_error = $14,
|
||||
revision = $15, updated_at = $16
|
||||
WHERE id = $17
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
instance.ClusterID,
|
||||
instance.Name,
|
||||
instance.Namespace,
|
||||
instance.RegistryID,
|
||||
instance.Repository,
|
||||
instance.Chart,
|
||||
instance.Version,
|
||||
instance.Description,
|
||||
valuesJSON,
|
||||
instance.ValuesYAML,
|
||||
instance.Status,
|
||||
instance.StatusReason,
|
||||
instance.LastOperation,
|
||||
instance.LastError,
|
||||
instance.Revision,
|
||||
instance.UpdatedAt,
|
||||
instance.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update instance: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除实例
|
||||
func (r *InstanceRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM instances WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete instance: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListByCluster 列出指定集群的所有实例
|
||||
func (r *InstanceRepository) ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
WHERE cluster_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instances: %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
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
|
||||
// List 列出所有实例
|
||||
func (r *InstanceRepository) List(ctx context.Context) ([]*entity.Instance, error) {
|
||||
query := `
|
||||
SELECT id, cluster_id, name, namespace, registry_id, repository, chart, version,
|
||||
description, values, values_yaml, status, status_reason, last_operation, last_error,
|
||||
revision, created_at, updated_at
|
||||
FROM instances
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list instances: %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
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&instance.ID,
|
||||
&instance.ClusterID,
|
||||
&instance.Name,
|
||||
&instance.Namespace,
|
||||
&instance.RegistryID,
|
||||
&instance.Repository,
|
||||
&instance.Chart,
|
||||
&instance.Version,
|
||||
&instance.Description,
|
||||
&valuesJSON,
|
||||
&instance.ValuesYAML,
|
||||
&instance.Status,
|
||||
&statusReason,
|
||||
&lastOperation,
|
||||
&lastError,
|
||||
&instance.Revision,
|
||||
&instance.CreatedAt,
|
||||
&instance.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan instance: %w", err)
|
||||
}
|
||||
|
||||
// 解析 JSON Values
|
||||
if len(valuesJSON) > 0 {
|
||||
if err := json.Unmarshal(valuesJSON, &instance.Values); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal values: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if statusReason.Valid {
|
||||
instance.StatusReason = statusReason.String
|
||||
}
|
||||
if lastOperation.Valid {
|
||||
instance.LastOperation = entity.InstanceOperation(lastOperation.String)
|
||||
}
|
||||
if lastError.Valid {
|
||||
instance.LastError = lastError.String
|
||||
}
|
||||
|
||||
instances = append(instances, instance)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return instances, nil
|
||||
}
|
||||
@ -0,0 +1,257 @@
|
||||
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"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/crypto"
|
||||
)
|
||||
|
||||
// RegistryRepository PostgreSQL Registry 仓储实现
|
||||
type RegistryRepository struct {
|
||||
db *DB
|
||||
encryptor crypto.Encryptor
|
||||
}
|
||||
|
||||
// NewRegistryRepository 创建 PostgreSQL Registry 仓储
|
||||
func NewRegistryRepository(db *DB, encryptor crypto.Encryptor) repository.RegistryRepository {
|
||||
return &RegistryRepository{
|
||||
db: db,
|
||||
encryptor: encryptor,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建 Registry
|
||||
func (r *RegistryRepository) Create(ctx context.Context, registry *entity.Registry) error {
|
||||
if registry.ID == "" {
|
||||
registry.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
// 加密密码
|
||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO registries (id, name, url, description, username, password, insecure, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
`
|
||||
|
||||
_, err = r.db.conn.ExecContext(ctx, query,
|
||||
registry.ID,
|
||||
registry.Name,
|
||||
registry.URL,
|
||||
registry.Description,
|
||||
registry.Username,
|
||||
encryptedPassword,
|
||||
registry.Insecure,
|
||||
registry.CreatedAt,
|
||||
registry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create registry: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据 ID 获取 Registry
|
||||
func (r *RegistryRepository) GetByID(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
query := `
|
||||
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
||||
FROM registries
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
®istry.ID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// GetByName 根据名称获取 Registry
|
||||
func (r *RegistryRepository) GetByName(ctx context.Context, name string) (*entity.Registry, error) {
|
||||
query := `
|
||||
SELECT id, name, url, description, username, password, insecure, created_at, updated_at
|
||||
FROM registries
|
||||
WHERE name = $1
|
||||
`
|
||||
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword string
|
||||
|
||||
err := r.db.conn.QueryRowContext(ctx, query, name).Scan(
|
||||
®istry.ID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get registry: %w", err)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
// Update 更新 Registry
|
||||
func (r *RegistryRepository) Update(ctx context.Context, registry *entity.Registry) error {
|
||||
registry.UpdatedAt = time.Now()
|
||||
|
||||
// 加密密码
|
||||
encryptedPassword, err := r.encryptor.Encrypt(registry.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt password: %w", err)
|
||||
}
|
||||
|
||||
query := `
|
||||
UPDATE registries
|
||||
SET name = $1, url = $2, description = $3, username = $4, password = $5,
|
||||
insecure = $6, updated_at = $7
|
||||
WHERE id = $8
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
registry.Name,
|
||||
registry.URL,
|
||||
registry.Description,
|
||||
registry.Username,
|
||||
encryptedPassword,
|
||||
registry.Insecure,
|
||||
registry.UpdatedAt,
|
||||
registry.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update registry: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 Registry
|
||||
func (r *RegistryRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM registries WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete registry: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM registries
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list registries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
registries := make([]*entity.Registry, 0)
|
||||
for rows.Next() {
|
||||
registry := &entity.Registry{}
|
||||
var encryptedPassword string
|
||||
|
||||
err := rows.Scan(
|
||||
®istry.ID,
|
||||
®istry.Name,
|
||||
®istry.URL,
|
||||
®istry.Description,
|
||||
®istry.Username,
|
||||
&encryptedPassword,
|
||||
®istry.Insecure,
|
||||
®istry.CreatedAt,
|
||||
®istry.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan registry: %w", err)
|
||||
}
|
||||
|
||||
// 解密密码
|
||||
registry.Password, err = r.encryptor.Decrypt(encryptedPassword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt password: %w", err)
|
||||
}
|
||||
|
||||
registries = append(registries, registry)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("rows iteration error: %w", err)
|
||||
}
|
||||
|
||||
return registries, nil
|
||||
}
|
||||
|
||||
@ -0,0 +1,204 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// UserRepository PostgreSQL 用户仓储实现
|
||||
type UserRepository struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
// NewUserRepository 创建 PostgreSQL 用户仓储
|
||||
func NewUserRepository(db *DB) repository.UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
|
||||
if user.ID == "" {
|
||||
user.ID = uuid.New().String()
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO users (id, username, password_hash, email, revoked_after, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`
|
||||
|
||||
_, err := r.db.conn.ExecContext(ctx, query,
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.PasswordHash,
|
||||
user.Email,
|
||||
user.RevokedAfter,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
user := &entity.User{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.RevokedAfter,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByUsername 根据用户名获取用户
|
||||
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*entity.User, error) {
|
||||
query := `
|
||||
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
|
||||
user := &entity.User{}
|
||||
err := r.db.conn.QueryRowContext(ctx, query, username).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.PasswordHash,
|
||||
&user.Email,
|
||||
&user.RevokedAfter,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, entity.ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Update 更新用户
|
||||
func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE users
|
||||
SET username = $1, password_hash = $2, email = $3, revoked_after = $4, updated_at = $5
|
||||
WHERE id = $6
|
||||
`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query,
|
||||
user.Username,
|
||||
user.PasswordHash,
|
||||
user.Email,
|
||||
user.RevokedAfter,
|
||||
user.UpdatedAt,
|
||||
user.ID,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除用户
|
||||
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM users WHERE id = $1`
|
||||
|
||||
result, err := r.db.conn.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get affected rows: %w", err)
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 列出所有用户
|
||||
func (r *UserRepository) List(ctx context.Context) ([]*entity.User, error) {
|
||||
query := `
|
||||
SELECT id, username, password_hash, email, revoked_after, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list 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.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
|
||||
}
|
||||
|
||||
137
backend/internal/bootstrap/config.go
Normal file
137
backend/internal/bootstrap/config.go
Normal file
@ -0,0 +1,137 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// BootstrapConfig 预注入配置
|
||||
type BootstrapConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Users []UserSeed `json:"users"`
|
||||
Registries []RegistrySeed `json:"registries"`
|
||||
Clusters []ClusterSeed `json:"clusters"`
|
||||
}
|
||||
|
||||
// UserSeed 用户预注入数据
|
||||
type UserSeed struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// RegistrySeed Registry 预注入数据
|
||||
type RegistrySeed struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Insecure bool `json:"insecure"`
|
||||
}
|
||||
|
||||
// ClusterSeed Cluster 预注入数据
|
||||
type ClusterSeed struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Description string `json:"description"`
|
||||
CAData string `json:"ca_data"`
|
||||
CertData string `json:"cert_data"`
|
||||
KeyData string `json:"key_data"`
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
// LoadBootstrapConfig 加载预注入配置
|
||||
// 支持从文件或环境变量加载
|
||||
//
|
||||
// 加载优先级:
|
||||
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
||||
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
||||
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据
|
||||
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||
// 1. 优先从环境变量加载
|
||||
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
||||
var config BootstrapConfig
|
||||
if err := json.Unmarshal([]byte(configJSON), &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse BOOTSTRAP_CONFIG_JSON: %w", err)
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// 2. 检查适配器模式
|
||||
adapterMode := os.Getenv("ADAPTER_MODE")
|
||||
|
||||
// Mock 模式: 使用配置文件(假数据)
|
||||
if adapterMode == "mock" {
|
||||
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
||||
if configPath == "" {
|
||||
configPath = filepath.Join("config", "bootstrap.json")
|
||||
}
|
||||
|
||||
// 检查文件是否存在
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
// 配置文件不存在,使用默认配置
|
||||
return GetDefaultBootstrapConfig(), nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read bootstrap config file %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config BootstrapConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse bootstrap config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据
|
||||
return GetDefaultBootstrapConfig(), nil
|
||||
}
|
||||
|
||||
// GetDefaultBootstrapConfig 获取默认的预注入配置(示例)
|
||||
func GetDefaultBootstrapConfig() *BootstrapConfig {
|
||||
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=",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
181
backend/internal/bootstrap/seeder.go
Normal file
181
backend/internal/bootstrap/seeder.go
Normal file
@ -0,0 +1,181 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/adapter/output"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/pkg/password"
|
||||
)
|
||||
|
||||
// Seeder 预注入管理器
|
||||
type Seeder struct {
|
||||
repos *output.Repositories
|
||||
passwordHasher *password.Hasher
|
||||
config *BootstrapConfig
|
||||
}
|
||||
|
||||
// NewSeeder 创建预注入管理器
|
||||
func NewSeeder(repos *output.Repositories, passwordHasher *password.Hasher, config *BootstrapConfig) *Seeder {
|
||||
return &Seeder{
|
||||
repos: repos,
|
||||
passwordHasher: passwordHasher,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// SeedAll 执行所有预注入
|
||||
func (s *Seeder) SeedAll(ctx context.Context) error {
|
||||
if !s.config.Enabled {
|
||||
log.Println(" ℹ️ Bootstrap seeding is disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println(" 🌱 Starting bootstrap seeding...")
|
||||
|
||||
// 1. 注入用户
|
||||
if err := s.seedUsers(ctx); err != nil {
|
||||
return fmt.Errorf("failed to seed users: %w", err)
|
||||
}
|
||||
|
||||
// 2. 注入 Registries
|
||||
if err := s.seedRegistries(ctx); err != nil {
|
||||
return fmt.Errorf("failed to seed registries: %w", err)
|
||||
}
|
||||
|
||||
// 3. 注入 Clusters
|
||||
if err := s.seedClusters(ctx); err != nil {
|
||||
return fmt.Errorf("failed to seed clusters: %w", err)
|
||||
}
|
||||
|
||||
log.Println(" ✅ Bootstrap seeding completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedUsers 注入用户
|
||||
func (s *Seeder) seedUsers(ctx context.Context) error {
|
||||
if len(s.config.Users) == 0 {
|
||||
log.Println(" ↳ No users to seed")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf(" ↳ Seeding %d user(s)...", len(s.config.Users))
|
||||
|
||||
for _, userSeed := range s.config.Users {
|
||||
// 检查用户是否已存在
|
||||
existingUser, _ := s.repos.UserRepo.GetByUsername(ctx, userSeed.Username)
|
||||
if existingUser != nil {
|
||||
log.Printf(" ⊙ User '%s' already exists, skipping", userSeed.Username)
|
||||
continue
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
passwordHash, err := s.passwordHasher.Hash(userSeed.Password)
|
||||
if err != nil {
|
||||
log.Printf(" ✗ Failed to hash password for user '%s': %v", userSeed.Username, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := entity.NewUser(userSeed.Username, passwordHash, userSeed.Email)
|
||||
user.ID = uuid.New().String()
|
||||
|
||||
if err := s.repos.UserRepo.Create(ctx, user); err != nil {
|
||||
log.Printf(" ✗ Failed to create user '%s': %v", userSeed.Username, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" ✓ User '%s' created", userSeed.Username)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedRegistries 注入 Registries
|
||||
func (s *Seeder) seedRegistries(ctx context.Context) error {
|
||||
if len(s.config.Registries) == 0 {
|
||||
log.Println(" ↳ No registries to seed")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf(" ↳ Seeding %d registry(ies)...", len(s.config.Registries))
|
||||
|
||||
for _, registrySeed := range s.config.Registries {
|
||||
// 检查 Registry 是否已存在
|
||||
existingRegistry, _ := s.repos.RegistryRepo.GetByName(ctx, registrySeed.Name)
|
||||
if existingRegistry != nil {
|
||||
log.Printf(" ⊙ Registry '%s' already exists, skipping", registrySeed.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建 Registry
|
||||
registry := &entity.Registry{
|
||||
ID: uuid.New().String(),
|
||||
Name: registrySeed.Name,
|
||||
URL: registrySeed.URL,
|
||||
Description: registrySeed.Description,
|
||||
Username: registrySeed.Username,
|
||||
Password: registrySeed.Password,
|
||||
Insecure: registrySeed.Insecure,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.repos.RegistryRepo.Create(ctx, registry); err != nil {
|
||||
log.Printf(" ✗ Failed to create registry '%s': %v", registrySeed.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" ✓ Registry '%s' created (credentials encrypted)", registrySeed.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedClusters 注入 Clusters
|
||||
func (s *Seeder) seedClusters(ctx context.Context) error {
|
||||
if len(s.config.Clusters) == 0 {
|
||||
log.Println(" ↳ No clusters to seed")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf(" ↳ Seeding %d cluster(s)...", len(s.config.Clusters))
|
||||
|
||||
for _, clusterSeed := range s.config.Clusters {
|
||||
// 检查 Cluster 是否已存在
|
||||
existingCluster, _ := s.repos.ClusterRepo.GetByName(ctx, clusterSeed.Name)
|
||||
if existingCluster != nil {
|
||||
log.Printf(" ⊙ Cluster '%s' already exists, skipping", clusterSeed.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// 创建 Cluster
|
||||
cluster := &entity.Cluster{
|
||||
ID: uuid.New().String(),
|
||||
Name: clusterSeed.Name,
|
||||
Host: clusterSeed.Host,
|
||||
Description: clusterSeed.Description,
|
||||
CAData: clusterSeed.CAData,
|
||||
CertData: clusterSeed.CertData,
|
||||
KeyData: clusterSeed.KeyData,
|
||||
Token: clusterSeed.Token,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.repos.ClusterRepo.Create(ctx, cluster); err != nil {
|
||||
log.Printf(" ✗ Failed to create cluster '%s': %v", clusterSeed.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf(" ✓ Cluster '%s' created (credentials encrypted)", clusterSeed.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
171
backend/internal/domain/entity/artifact.go
Normal file
171
backend/internal/domain/entity/artifact.go
Normal file
@ -0,0 +1,171 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ArtifactType Artifact 类型
|
||||
type ArtifactType string
|
||||
|
||||
const (
|
||||
ArtifactTypeChart ArtifactType = "chart" // Helm Chart
|
||||
ArtifactTypeImage ArtifactType = "image" // Docker/OCI Image
|
||||
ArtifactTypeOther ArtifactType = "other" // Other types
|
||||
)
|
||||
|
||||
// Artifact OCI Artifact 领域实体
|
||||
type Artifact struct {
|
||||
RegistryID string
|
||||
Repository string
|
||||
Tag string
|
||||
Digest string
|
||||
Type ArtifactType
|
||||
Size int64
|
||||
MediaType string
|
||||
ConfigType string // Config layer 的 mediaType (用于更准确的类型判断)
|
||||
Annotations map[string]string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Repository 仓库信息
|
||||
type Repository struct {
|
||||
RegistryID string
|
||||
Name string
|
||||
TagCount int
|
||||
}
|
||||
|
||||
// NewArtifact 创建新 Artifact
|
||||
func NewArtifact(registryID, repository, tag, digest string) *Artifact {
|
||||
return &Artifact{
|
||||
RegistryID: registryID,
|
||||
Repository: repository,
|
||||
Tag: tag,
|
||||
Digest: digest,
|
||||
Annotations: make(map[string]string),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// SetType 设置 Artifact 类型(根据 mediaType 识别为 chart | image | other)
|
||||
// 已废弃:请使用 DetermineType() 方法,它提供更准确的类型判断
|
||||
func (a *Artifact) SetType(mediaType string) {
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
"vnd.oci", "oci.image", "opencontainers", "container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest") || strings.Contains(lowerMediaType, "container"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineType 智能判断 Artifact 类型(综合多种信息)
|
||||
// 优先级:
|
||||
// 1. ConfigType (config.mediaType) - 最准确
|
||||
// 2. Annotations - 可能包含类型标注
|
||||
// 3. Repository 名称 - charts/ 前缀暗示
|
||||
// 4. MediaType - 兜底判断
|
||||
func (a *Artifact) DetermineType() {
|
||||
containsAny := func(target string, keywords ...string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if keyword != "" && strings.Contains(target, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 优先检查 ConfigType(最准确的判断方式)
|
||||
if a.ConfigType != "" {
|
||||
lowerConfigType := strings.ToLower(strings.TrimSpace(a.ConfigType))
|
||||
|
||||
// Helm Chart 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"helm.config", "cncf.helm", "helm.chart", "chart.content",
|
||||
) {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
|
||||
// Docker/OCI Image 的 config.mediaType
|
||||
if containsAny(lowerConfigType,
|
||||
"docker.container.image", "oci.image.config",
|
||||
) {
|
||||
a.Type = ArtifactTypeImage
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查 Annotations
|
||||
for key, value := range a.Annotations {
|
||||
lowerKey := strings.ToLower(key)
|
||||
lowerValue := strings.ToLower(value)
|
||||
|
||||
if containsAny(lowerKey, "helm", "chart") ||
|
||||
containsAny(lowerValue, "helm", "chart") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 检查 Repository 名称(辅助判断)
|
||||
if strings.HasPrefix(strings.ToLower(a.Repository), "charts/") {
|
||||
// charts/ 开头的仓库很可能是 Helm Chart
|
||||
// 但需要结合 MediaType 进一步确认
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
// 如果是 OCI manifest 格式,很可能是以 OCI 格式存储的 Helm Chart
|
||||
if strings.Contains(lowerMediaType, "oci.image.manifest") ||
|
||||
strings.Contains(lowerMediaType, "vnd.oci") {
|
||||
a.Type = ArtifactTypeChart
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 回退到基于 MediaType 的判断(兜底逻辑)
|
||||
lowerMediaType := strings.ToLower(strings.TrimSpace(a.MediaType))
|
||||
|
||||
switch {
|
||||
case lowerMediaType == "":
|
||||
a.Type = ArtifactTypeOther
|
||||
case containsAny(lowerMediaType,
|
||||
"helm", "cncf.helm", "helm.chart", "helm+", "chart+json", "chart.v1", "helm-package", "helm.config",
|
||||
):
|
||||
a.Type = ArtifactTypeChart
|
||||
case containsAny(lowerMediaType,
|
||||
"docker", "vnd.docker", "docker.distribution", "docker.container.image",
|
||||
):
|
||||
a.Type = ArtifactTypeImage
|
||||
case strings.Contains(lowerMediaType, "image") || strings.Contains(lowerMediaType, "manifest"):
|
||||
a.Type = ArtifactTypeImage
|
||||
default:
|
||||
a.Type = ArtifactTypeOther
|
||||
}
|
||||
}
|
||||
|
||||
// IsChart 判断是否为 Helm Chart
|
||||
func (a *Artifact) IsChart() bool {
|
||||
return a.Type == ArtifactTypeChart
|
||||
}
|
||||
|
||||
103
backend/internal/domain/entity/cluster.go
Normal file
103
backend/internal/domain/entity/cluster.go
Normal file
@ -0,0 +1,103 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cluster Kubernetes 集群领域实体
|
||||
type Cluster struct {
|
||||
ID string
|
||||
Name string
|
||||
Host string // Kubernetes API Server URL
|
||||
CAData string // Base64 encoded CA certificate
|
||||
CertData string // Base64 encoded client certificate
|
||||
KeyData string // Base64 encoded client key
|
||||
Token string // Bearer token (alternative to cert auth)
|
||||
Description string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewCluster 创建新集群
|
||||
func NewCluster(name, host string) *Cluster {
|
||||
now := time.Now()
|
||||
return &Cluster{
|
||||
Name: name,
|
||||
Host: host,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Update 更新集群信息
|
||||
func (c *Cluster) Update(name, host, description string) {
|
||||
if name != "" {
|
||||
c.Name = name
|
||||
}
|
||||
if host != "" {
|
||||
c.Host = host
|
||||
}
|
||||
c.Description = description
|
||||
c.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetCertAuth 设置证书认证
|
||||
func (c *Cluster) SetCertAuth(caData, certData, keyData string) {
|
||||
c.CAData = caData
|
||||
c.CertData = certData
|
||||
c.KeyData = keyData
|
||||
c.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetTokenAuth 设置 Token 认证
|
||||
func (c *Cluster) SetTokenAuth(token string) {
|
||||
c.Token = token
|
||||
c.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Validate 验证集群配置
|
||||
func (c *Cluster) Validate() error {
|
||||
if c.Name == "" {
|
||||
return ErrInvalidClusterName
|
||||
}
|
||||
if c.Host == "" {
|
||||
return ErrInvalidClusterHost
|
||||
}
|
||||
// 必须有认证方式:证书或 Token
|
||||
if (c.CertData == "" || c.KeyData == "") && c.Token == "" {
|
||||
return ErrInvalidClusterAuth
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKubeConfig 生成 kubeconfig 内容
|
||||
func (c *Cluster) GetKubeConfig() string {
|
||||
// 如果 CAData 已经包含完整的 kubeconfig,直接返回
|
||||
if len(c.CAData) > 100 && (c.CAData[:11] == "apiVersion:" || c.CAData[:5] == "kind:") {
|
||||
return c.CAData
|
||||
}
|
||||
|
||||
// 否则从证书数据生成 kubeconfig
|
||||
kubeconfig := `apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- cluster:
|
||||
certificate-authority-data: ` + c.CAData + `
|
||||
server: ` + c.Host + `
|
||||
name: ` + c.Name + `
|
||||
contexts:
|
||||
- context:
|
||||
cluster: ` + c.Name + `
|
||||
user: ` + c.Name + `
|
||||
name: ` + c.Name + `
|
||||
current-context: ` + c.Name + `
|
||||
users:
|
||||
- name: ` + c.Name + `
|
||||
user:
|
||||
client-certificate-data: ` + c.CertData + `
|
||||
client-key-data: ` + c.KeyData + `
|
||||
`
|
||||
|
||||
return kubeconfig
|
||||
}
|
||||
|
||||
40
backend/internal/domain/entity/errors.go
Normal file
40
backend/internal/domain/entity/errors.go
Normal file
@ -0,0 +1,40 @@
|
||||
package entity
|
||||
|
||||
import "errors"
|
||||
|
||||
// 领域错误定义
|
||||
var (
|
||||
// User errors
|
||||
ErrInvalidUsername = errors.New("invalid username")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserExists = errors.New("user already exists")
|
||||
ErrTokenRevoked = errors.New("token has been revoked")
|
||||
|
||||
// Cluster errors
|
||||
ErrInvalidClusterName = errors.New("invalid cluster name")
|
||||
ErrInvalidClusterHost = errors.New("invalid cluster host")
|
||||
ErrInvalidClusterAuth = errors.New("invalid cluster authentication config")
|
||||
ErrClusterNotFound = errors.New("cluster not found")
|
||||
ErrClusterExists = errors.New("cluster already exists")
|
||||
|
||||
// Registry errors
|
||||
ErrInvalidRegistryName = errors.New("invalid registry name")
|
||||
ErrInvalidRegistryURL = errors.New("invalid registry URL")
|
||||
ErrRegistryNotFound = errors.New("registry not found")
|
||||
ErrRegistryExists = errors.New("registry already exists")
|
||||
|
||||
// Instance errors
|
||||
ErrInvalidClusterID = errors.New("invalid cluster ID")
|
||||
ErrInvalidInstanceName = errors.New("invalid instance name")
|
||||
ErrInvalidNamespace = errors.New("invalid namespace")
|
||||
ErrInvalidChart = errors.New("invalid chart name")
|
||||
ErrInvalidVersion = errors.New("invalid version")
|
||||
ErrInstanceNotFound = errors.New("instance not found")
|
||||
ErrInstanceExists = errors.New("instance already exists")
|
||||
|
||||
// Artifact errors
|
||||
ErrArtifactNotFound = errors.New("artifact not found")
|
||||
ErrRepositoryNotFound = errors.New("repository not found")
|
||||
ErrValuesSchemaNotFound = errors.New("values schema not found")
|
||||
)
|
||||
185
backend/internal/domain/entity/instance.go
Normal file
185
backend/internal/domain/entity/instance.go
Normal file
@ -0,0 +1,185 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// InstanceStatus 实例状态
|
||||
type InstanceStatus string
|
||||
|
||||
const (
|
||||
StatusDeployed InstanceStatus = "deployed"
|
||||
StatusUninstalled InstanceStatus = "uninstalled"
|
||||
StatusSuperseded InstanceStatus = "superseded"
|
||||
StatusFailed InstanceStatus = "failed"
|
||||
StatusPending InstanceStatus = "pending-install"
|
||||
StatusUpgrading InstanceStatus = "pending-upgrade"
|
||||
StatusRollingBack InstanceStatus = "pending-rollback"
|
||||
StatusTerminating InstanceStatus = "pending-delete"
|
||||
StatusUnknown InstanceStatus = "unknown"
|
||||
)
|
||||
|
||||
// InstanceOperation 实例操作类型
|
||||
type InstanceOperation string
|
||||
|
||||
const (
|
||||
OperationNone InstanceOperation = ""
|
||||
OperationInstall InstanceOperation = "install"
|
||||
OperationUpgrade InstanceOperation = "upgrade"
|
||||
OperationRollback InstanceOperation = "rollback"
|
||||
OperationDelete InstanceOperation = "delete"
|
||||
OperationSync InstanceOperation = "sync"
|
||||
)
|
||||
|
||||
// Instance Helm 应用实例领域实体
|
||||
type Instance struct {
|
||||
ID string
|
||||
ClusterID string
|
||||
Name string // Helm Release Name
|
||||
Namespace string
|
||||
RegistryID string
|
||||
Repository string // OCI Repository (e.g., charts/app)
|
||||
Chart string // Chart Name
|
||||
Version string // Chart Version
|
||||
Description string
|
||||
Values map[string]interface{} // Helm Values (JSON)
|
||||
ValuesYAML string // Helm Values (YAML format)
|
||||
Status InstanceStatus
|
||||
StatusReason string
|
||||
LastOperation InstanceOperation
|
||||
LastError string
|
||||
Revision int // Helm Release Revision
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewInstance 创建新实例
|
||||
func NewInstance(clusterID, name, namespace, registryID, repository, chart, version string) *Instance {
|
||||
now := time.Now()
|
||||
return &Instance{
|
||||
ClusterID: clusterID,
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
RegistryID: registryID,
|
||||
Repository: repository,
|
||||
Chart: chart,
|
||||
Version: version,
|
||||
Status: StatusPending,
|
||||
StatusReason: "Pending install",
|
||||
LastOperation: OperationInstall,
|
||||
Revision: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// SetValues 设置 Helm Values
|
||||
func (i *Instance) SetValues(values map[string]interface{}) {
|
||||
i.Values = values
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetValuesYAML 设置 YAML 格式的 Values
|
||||
func (i *Instance) SetValuesYAML(yaml string) {
|
||||
i.ValuesYAML = yaml
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// UpdateStatus 更新实例状态
|
||||
func (i *Instance) UpdateStatus(status InstanceStatus, revision int) {
|
||||
i.Status = status
|
||||
i.Revision = revision
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// BeginOperation 标记开始执行某个操作
|
||||
func (i *Instance) BeginOperation(op InstanceOperation, reason string) {
|
||||
i.LastOperation = op
|
||||
if pendingStatus := pendingStatusForOperation(op); pendingStatus != "" {
|
||||
i.Status = pendingStatus
|
||||
}
|
||||
if reason != "" {
|
||||
i.StatusReason = reason
|
||||
}
|
||||
i.LastError = ""
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// MarkSuccess 标记操作成功
|
||||
func (i *Instance) MarkSuccess(status InstanceStatus, revision int, reason string) {
|
||||
if status != "" {
|
||||
i.Status = status
|
||||
}
|
||||
if revision > 0 {
|
||||
i.Revision = revision
|
||||
}
|
||||
i.StatusReason = reason
|
||||
i.LastError = ""
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// MarkFailure 标记操作失败
|
||||
func (i *Instance) MarkFailure(reason string, err error) {
|
||||
i.Status = StatusFailed
|
||||
if reason != "" {
|
||||
i.StatusReason = reason
|
||||
}
|
||||
if err != nil {
|
||||
i.LastError = err.Error()
|
||||
}
|
||||
i.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
func pendingStatusForOperation(op InstanceOperation) InstanceStatus {
|
||||
switch op {
|
||||
case OperationInstall:
|
||||
return StatusPending
|
||||
case OperationUpgrade:
|
||||
return StatusUpgrading
|
||||
case OperationRollback:
|
||||
return StatusRollingBack
|
||||
case OperationDelete:
|
||||
return StatusTerminating
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade 升级实例
|
||||
func (i *Instance) Upgrade(version string, values map[string]interface{}) {
|
||||
i.Version = version
|
||||
if values != nil {
|
||||
i.Values = values
|
||||
}
|
||||
i.BeginOperation(OperationUpgrade, "Pending upgrade")
|
||||
}
|
||||
|
||||
// Validate 验证实例配置
|
||||
func (i *Instance) Validate() error {
|
||||
if i.ClusterID == "" {
|
||||
return ErrInvalidClusterID
|
||||
}
|
||||
if i.Name == "" {
|
||||
return ErrInvalidInstanceName
|
||||
}
|
||||
if i.Namespace == "" {
|
||||
return ErrInvalidNamespace
|
||||
}
|
||||
if i.Chart == "" {
|
||||
return ErrInvalidChart
|
||||
}
|
||||
if i.Version == "" {
|
||||
return ErrInvalidVersion
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReleaseHistory Helm Release 历史记录
|
||||
type ReleaseHistory struct {
|
||||
Revision int
|
||||
Updated time.Time
|
||||
Status InstanceStatus
|
||||
Chart string
|
||||
AppVersion string
|
||||
Description string
|
||||
}
|
||||
43
backend/internal/domain/entity/instance_entry.go
Normal file
43
backend/internal/domain/entity/instance_entry.go
Normal file
@ -0,0 +1,43 @@
|
||||
package entity
|
||||
|
||||
// InstanceEntry 描述实例关联的访问入口(Service、Ingress 等)
|
||||
type InstanceEntry struct {
|
||||
Kind string
|
||||
Name string
|
||||
Namespace string
|
||||
Type string
|
||||
ClusterIP string
|
||||
ExternalIPs []string
|
||||
LoadBalancerIngress []string
|
||||
Ports []InstanceEntryPort
|
||||
Hosts []InstanceEntryHost
|
||||
TLS []InstanceEntryTLS
|
||||
}
|
||||
|
||||
// InstanceEntryPort Service 端口信息
|
||||
type InstanceEntryPort struct {
|
||||
Name string
|
||||
Protocol string
|
||||
Port int32
|
||||
TargetPort string
|
||||
NodePort int32
|
||||
}
|
||||
|
||||
// InstanceEntryHost Ingress Host 配置
|
||||
type InstanceEntryHost struct {
|
||||
Host string
|
||||
Paths []InstanceEntryPath
|
||||
}
|
||||
|
||||
// InstanceEntryPath Ingress path 详情
|
||||
type InstanceEntryPath struct {
|
||||
Path string
|
||||
ServiceName string
|
||||
ServicePort string
|
||||
}
|
||||
|
||||
// InstanceEntryTLS Ingress TLS 配置
|
||||
type InstanceEntryTLS struct {
|
||||
Hosts []string
|
||||
SecretName string
|
||||
}
|
||||
83
backend/internal/domain/entity/metrics.go
Normal file
83
backend/internal/domain/entity/metrics.go
Normal file
@ -0,0 +1,83 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// ClusterMetrics 集群监控指标
|
||||
type ClusterMetrics struct {
|
||||
ClusterID string `json:"cluster_id"`
|
||||
ClusterName string `json:"cluster_name"`
|
||||
Status string `json:"status"` // healthy, warning, error, unknown
|
||||
Uptime string `json:"uptime"`
|
||||
NodeCount int `json:"node_count"`
|
||||
PodCount int `json:"pod_count"`
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
|
||||
// 集群级别资源汇总
|
||||
TotalCPU string `json:"total_cpu"` // 如 "8 cores"
|
||||
TotalMemory string `json:"total_memory"` // 如 "32 GB"
|
||||
TotalGPU int `json:"total_gpu"` // GPU 总数
|
||||
|
||||
UsedCPU string `json:"used_cpu"` // 如 "4.5 cores"
|
||||
UsedMemory string `json:"used_memory"` // 如 "16 GB"
|
||||
UsedGPU int `json:"used_gpu"` // 使用的 GPU 数
|
||||
|
||||
CPUUsage float64 `json:"cpu_usage"` // 百分比
|
||||
MemoryUsage float64 `json:"memory_usage"` // 百分比
|
||||
GPUUsage float64 `json:"gpu_usage"` // 百分比
|
||||
|
||||
// 单机资源最大值
|
||||
MaxNodeCPU string `json:"max_node_cpu"` // 单机最大CPU容量,如 "8 cores"
|
||||
MaxNodeMemory string `json:"max_node_memory"` // 单机最大内存容量,如 "32 GB"
|
||||
MaxNodeGPU int `json:"max_node_gpu"` // 单机最大GPU数量
|
||||
MaxNodeCPUUsage float64 `json:"max_node_cpu_usage"` // 单机最高CPU使用率
|
||||
MaxNodeMemUsage float64 `json:"max_node_mem_usage"` // 单机最高内存使用率
|
||||
MaxNodeGPUUsage float64 `json:"max_node_gpu_usage"` // 单机最高GPU使用率
|
||||
|
||||
// 节点列表(简化信息)
|
||||
Nodes []NodeMetrics `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// NodeMetrics 节点监控指标
|
||||
type NodeMetrics struct {
|
||||
NodeName string `json:"node_name"`
|
||||
Status string `json:"status"` // Ready, NotReady
|
||||
Role string `json:"role"` // control-plane, worker
|
||||
Age string `json:"age"`
|
||||
PodCount int `json:"pod_count"`
|
||||
|
||||
// CPU 资源
|
||||
CPUCapacity string `json:"cpu_capacity"` // 如 "4 cores"
|
||||
CPUAllocatable string `json:"cpu_allocatable"`
|
||||
CPUUsage string `json:"cpu_usage"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
|
||||
// 内存资源
|
||||
MemoryCapacity string `json:"memory_capacity"` // 如 "16 GB"
|
||||
MemoryAllocatable string `json:"memory_allocatable"`
|
||||
MemoryUsage string `json:"memory_usage"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
|
||||
// GPU 资源(如果有)
|
||||
GPUCapacity int `json:"gpu_capacity"` // GPU 总数
|
||||
GPUUsage int `json:"gpu_usage"` // 已使用的 GPU
|
||||
GPUPercent float64 `json:"gpu_percent"`
|
||||
GPUType string `json:"gpu_type,omitempty"` // GPU 型号,如 "NVIDIA-Tesla-T4"
|
||||
|
||||
// 其他信息
|
||||
OSImage string `json:"os_image,omitempty"`
|
||||
KernelVersion string `json:"kernel_version,omitempty"`
|
||||
ContainerRuntime string `json:"container_runtime,omitempty"`
|
||||
KubeletVersion string `json:"kubelet_version,omitempty"`
|
||||
}
|
||||
|
||||
// MonitoringSummary 监控汇总
|
||||
type MonitoringSummary struct {
|
||||
TotalClusters int `json:"total_clusters"`
|
||||
HealthyClusters int `json:"healthy_clusters"`
|
||||
WarningClusters int `json:"warning_clusters"`
|
||||
ErrorClusters int `json:"error_clusters"`
|
||||
TotalNodes int `json:"total_nodes"`
|
||||
TotalPods int `json:"total_pods"`
|
||||
LastUpdate time.Time `json:"last_update"`
|
||||
}
|
||||
|
||||
60
backend/internal/domain/entity/registry.go
Normal file
60
backend/internal/domain/entity/registry.go
Normal file
@ -0,0 +1,60 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registry OCI Registry 领域实体
|
||||
type Registry struct {
|
||||
ID string
|
||||
Name string
|
||||
URL string
|
||||
Description string
|
||||
Username string
|
||||
Password string
|
||||
Insecure bool // 是否跳过 TLS 验证
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewRegistry 创建新 Registry
|
||||
func NewRegistry(name, url string) *Registry {
|
||||
now := time.Now()
|
||||
return &Registry{
|
||||
Name: name,
|
||||
URL: url,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Update 更新 Registry 信息
|
||||
func (r *Registry) Update(name, url, description string) {
|
||||
if name != "" {
|
||||
r.Name = name
|
||||
}
|
||||
if url != "" {
|
||||
r.URL = url
|
||||
}
|
||||
r.Description = description
|
||||
r.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// SetCredentials 设置认证凭据
|
||||
func (r *Registry) SetCredentials(username, password string) {
|
||||
r.Username = username
|
||||
r.Password = password
|
||||
r.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Validate 验证 Registry 配置
|
||||
func (r *Registry) Validate() error {
|
||||
if r.Name == "" {
|
||||
return ErrInvalidRegistryName
|
||||
}
|
||||
if r.URL == "" {
|
||||
return ErrInvalidRegistryURL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
54
backend/internal/domain/entity/user.go
Normal file
54
backend/internal/domain/entity/user.go
Normal file
@ -0,0 +1,54 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 用户领域实体
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
PasswordHash string
|
||||
Email string
|
||||
RevokedAfter time.Time // 全局 Token 撤销时间
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewUser 创建新用户
|
||||
func NewUser(username, passwordHash, email string) *User {
|
||||
now := time.Now()
|
||||
return &User{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Email: email,
|
||||
RevokedAfter: time.Unix(0, 0), // 初始值:1970-01-01
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdatePassword 更新密码(会触发全局登出)
|
||||
func (u *User) UpdatePassword(newPasswordHash string) {
|
||||
u.PasswordHash = newPasswordHash
|
||||
u.RevokedAfter = time.Now() // 撤销所有旧 Token
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// RevokeAllTokens 撤销所有 Token(强制全局登出)
|
||||
func (u *User) RevokeAllTokens() {
|
||||
u.RevokedAfter = time.Now()
|
||||
u.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
// Validate 验证用户数据
|
||||
func (u *User) Validate() error {
|
||||
if u.Username == "" {
|
||||
return ErrInvalidUsername
|
||||
}
|
||||
if u.PasswordHash == "" {
|
||||
return ErrInvalidPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
28
backend/internal/domain/repository/cluster_repository.go
Normal file
28
backend/internal/domain/repository/cluster_repository.go
Normal file
@ -0,0 +1,28 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// ClusterRepository 集群仓储接口(Output Port)
|
||||
type ClusterRepository interface {
|
||||
// Create 创建集群
|
||||
Create(ctx context.Context, cluster *entity.Cluster) error
|
||||
|
||||
// GetByID 根据 ID 获取集群
|
||||
GetByID(ctx context.Context, id string) (*entity.Cluster, error)
|
||||
|
||||
// GetByName 根据名称获取集群
|
||||
GetByName(ctx context.Context, name string) (*entity.Cluster, error)
|
||||
|
||||
// Update 更新集群
|
||||
Update(ctx context.Context, cluster *entity.Cluster) error
|
||||
|
||||
// Delete 删除集群
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// List 列出所有集群
|
||||
List(ctx context.Context) ([]*entity.Cluster, error)
|
||||
}
|
||||
|
||||
34
backend/internal/domain/repository/helm_client.go
Normal file
34
backend/internal/domain/repository/helm_client.go
Normal file
@ -0,0 +1,34 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// HelmClient Helm 客户端接口(Output Port)
|
||||
type HelmClient interface {
|
||||
// Install 安装 Helm Chart
|
||||
Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||
|
||||
// Upgrade 升级 Helm Release
|
||||
Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error
|
||||
|
||||
// Uninstall 卸载 Helm Release
|
||||
Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error
|
||||
|
||||
// Rollback 回滚 Helm Release
|
||||
Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error
|
||||
|
||||
// GetStatus 获取 Release 状态
|
||||
GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error)
|
||||
|
||||
// GetHistory 获取 Release 历史
|
||||
GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error)
|
||||
|
||||
// List 列出集群中的所有 Releases
|
||||
List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error)
|
||||
|
||||
// GetValues 获取 Release 的 values
|
||||
GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error)
|
||||
}
|
||||
|
||||
13
backend/internal/domain/repository/instance_entry_client.go
Normal file
13
backend/internal/domain/repository/instance_entry_client.go
Normal file
@ -0,0 +1,13 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// InstanceEntryClient 从 Kubernetes 集群中查询实例访问入口的接口
|
||||
type InstanceEntryClient interface {
|
||||
// ListEntries 返回指定实例(Helm Release)相关的 Service/Ingress 等入口信息
|
||||
ListEntries(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) ([]*entity.InstanceEntry, error)
|
||||
}
|
||||
31
backend/internal/domain/repository/instance_repository.go
Normal file
31
backend/internal/domain/repository/instance_repository.go
Normal file
@ -0,0 +1,31 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// InstanceRepository 实例仓储接口(Output Port)
|
||||
type InstanceRepository interface {
|
||||
// Create 创建实例
|
||||
Create(ctx context.Context, instance *entity.Instance) error
|
||||
|
||||
// GetByID 根据 ID 获取实例
|
||||
GetByID(ctx context.Context, id string) (*entity.Instance, error)
|
||||
|
||||
// GetByClusterAndName 根据集群 ID 和名称获取实例
|
||||
GetByClusterAndName(ctx context.Context, clusterID, name string) (*entity.Instance, error)
|
||||
|
||||
// Update 更新实例
|
||||
Update(ctx context.Context, instance *entity.Instance) error
|
||||
|
||||
// Delete 删除实例
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// ListByCluster 列出指定集群的所有实例
|
||||
ListByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error)
|
||||
|
||||
// List 列出所有实例
|
||||
List(ctx context.Context) ([]*entity.Instance, error)
|
||||
}
|
||||
|
||||
17
backend/internal/domain/repository/metrics_client.go
Normal file
17
backend/internal/domain/repository/metrics_client.go
Normal file
@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// MetricsClient 定义获取集群监控指标的接口
|
||||
type MetricsClient interface {
|
||||
// GetClusterMetrics 获取集群的监控指标
|
||||
GetClusterMetrics(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error)
|
||||
|
||||
// GetNodeMetrics 获取集群的节点指标
|
||||
GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error)
|
||||
}
|
||||
|
||||
32
backend/internal/domain/repository/oci_client.go
Normal file
32
backend/internal/domain/repository/oci_client.go
Normal file
@ -0,0 +1,32 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// OCIClient OCI Registry 客户端接口(Output Port)
|
||||
type OCIClient interface {
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error)
|
||||
|
||||
// ListArtifacts 列出指定 repository 的所有 artifacts
|
||||
// mediaTypeFilter: "all", "image", "chart", "other" - 使用模糊匹配过滤
|
||||
ListArtifacts(ctx context.Context, registry *entity.Registry, repository, mediaTypeFilter string) ([]*entity.Artifact, error)
|
||||
|
||||
// GetArtifact 获取指定 artifact 的详细信息
|
||||
GetArtifact(ctx context.Context, registry *entity.Registry, repository, reference string) (*entity.Artifact, error)
|
||||
|
||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||
GetValuesSchema(ctx context.Context, registry *entity.Registry, repository, reference string) (string, error)
|
||||
|
||||
// PullArtifact 下载 artifact 到本地
|
||||
PullArtifact(ctx context.Context, registry *entity.Registry, repository, reference, destPath string) error
|
||||
|
||||
// PushArtifact 推送 artifact 到 Registry
|
||||
PushArtifact(ctx context.Context, registry *entity.Registry, repository, tag, sourcePath string) error
|
||||
|
||||
// CheckHealth 检查 Registry 健康状态
|
||||
CheckHealth(ctx context.Context, registry *entity.Registry) error
|
||||
}
|
||||
|
||||
28
backend/internal/domain/repository/registry_repository.go
Normal file
28
backend/internal/domain/repository/registry_repository.go
Normal file
@ -0,0 +1,28 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// RegistryRepository Registry 仓储接口(Output Port)
|
||||
type RegistryRepository interface {
|
||||
// Create 创建 Registry
|
||||
Create(ctx context.Context, registry *entity.Registry) error
|
||||
|
||||
// GetByID 根据 ID 获取 Registry
|
||||
GetByID(ctx context.Context, id string) (*entity.Registry, error)
|
||||
|
||||
// GetByName 根据名称获取 Registry
|
||||
GetByName(ctx context.Context, name string) (*entity.Registry, error)
|
||||
|
||||
// Update 更新 Registry
|
||||
Update(ctx context.Context, registry *entity.Registry) error
|
||||
|
||||
// Delete 删除 Registry
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// List 列出所有 Registries
|
||||
List(ctx context.Context) ([]*entity.Registry, error)
|
||||
}
|
||||
|
||||
28
backend/internal/domain/repository/user_repository.go
Normal file
28
backend/internal/domain/repository/user_repository.go
Normal file
@ -0,0 +1,28 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
)
|
||||
|
||||
// UserRepository 用户仓储接口(Output Port)
|
||||
type UserRepository interface {
|
||||
// Create 创建用户
|
||||
Create(ctx context.Context, user *entity.User) error
|
||||
|
||||
// GetByID 根据 ID 获取用户
|
||||
GetByID(ctx context.Context, id string) (*entity.User, error)
|
||||
|
||||
// GetByUsername 根据用户名获取用户
|
||||
GetByUsername(ctx context.Context, username string) (*entity.User, error)
|
||||
|
||||
// Update 更新用户
|
||||
Update(ctx context.Context, user *entity.User) error
|
||||
|
||||
// Delete 删除用户
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// List 列出所有用户
|
||||
List(ctx context.Context) ([]*entity.User, error)
|
||||
}
|
||||
|
||||
80
backend/internal/domain/service/artifact_service.go
Normal file
80
backend/internal/domain/service/artifact_service.go
Normal file
@ -0,0 +1,80 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// ArtifactService Artifact 浏览领域服务
|
||||
type ArtifactService struct {
|
||||
registryRepo repository.RegistryRepository
|
||||
ociClient repository.OCIClient
|
||||
}
|
||||
|
||||
// NewArtifactService 创建 Artifact 服务
|
||||
func NewArtifactService(
|
||||
registryRepo repository.RegistryRepository,
|
||||
ociClient repository.OCIClient,
|
||||
) *ArtifactService {
|
||||
return &ArtifactService{
|
||||
registryRepo: registryRepo,
|
||||
ociClient: ociClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRegistry 获取 Registry 信息
|
||||
func (s *ArtifactService) GetRegistry(ctx context.Context, registryID string) (*entity.Registry, error) {
|
||||
return s.registryRepo.GetByID(ctx, registryID)
|
||||
}
|
||||
|
||||
// ListRepositories 列出 Registry 中的所有 repositories
|
||||
func (s *ArtifactService) ListRepositories(ctx context.Context, registryID string) ([]string, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.ListRepositories(ctx, registry)
|
||||
}
|
||||
|
||||
// ListArtifacts 列出 repository 中的所有 artifacts
|
||||
func (s *ArtifactService) ListArtifacts(ctx context.Context, registryID, repository, mediaTypeFilter string) ([]*entity.Artifact, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.ListArtifacts(ctx, registry, repository, mediaTypeFilter)
|
||||
}
|
||||
|
||||
// GetArtifact 获取 artifact 详情
|
||||
func (s *ArtifactService) GetArtifact(ctx context.Context, registryID, repository, reference string) (*entity.Artifact, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.GetArtifact(ctx, registry, repository, reference)
|
||||
}
|
||||
|
||||
// GetValuesSchema 获取 Helm Chart 的 values schema
|
||||
func (s *ArtifactService) GetValuesSchema(ctx context.Context, registryID, repository, reference string) (string, error) {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return "", entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.GetValuesSchema(ctx, registry, repository, reference)
|
||||
}
|
||||
|
||||
// PullArtifact 下载 artifact
|
||||
func (s *ArtifactService) PullArtifact(ctx context.Context, registryID, repository, reference, destPath string) error {
|
||||
registry, err := s.registryRepo.GetByID(ctx, registryID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.PullArtifact(ctx, registry, repository, reference, destPath)
|
||||
}
|
||||
|
||||
165
backend/internal/domain/service/auth_service.go
Normal file
165
backend/internal/domain/service/auth_service.go
Normal file
@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// AuthService 认证领域服务
|
||||
type AuthService struct {
|
||||
userRepo repository.UserRepository
|
||||
passwordHasher PasswordHasher
|
||||
tokenGenerator TokenGenerator
|
||||
}
|
||||
|
||||
// PasswordHasher 密码哈希接口
|
||||
type PasswordHasher interface {
|
||||
Hash(password string) (string, error)
|
||||
Verify(password, hash string) error
|
||||
}
|
||||
|
||||
// TokenGenerator Token 生成器接口
|
||||
type TokenGenerator interface {
|
||||
Generate(userID, username string) (accessToken, refreshToken string, err error)
|
||||
Verify(token string) (userID, username string, err error)
|
||||
VerifyWithIssuedAt(token string) (userID, username string, issuedAt int64, err error)
|
||||
Refresh(refreshToken string) (newAccessToken string, err error)
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务
|
||||
func NewAuthService(
|
||||
userRepo repository.UserRepository,
|
||||
passwordHasher PasswordHasher,
|
||||
tokenGenerator TokenGenerator,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
tokenGenerator: tokenGenerator,
|
||||
}
|
||||
}
|
||||
|
||||
// Register 注册新用户(仅需用户名和密码,邮箱将自动补全)
|
||||
func (s *AuthService) Register(ctx context.Context, username, password string) (*entity.User, error) {
|
||||
// 检查用户是否已存在
|
||||
existingUser, _ := s.userRepo.GetByUsername(ctx, username)
|
||||
if existingUser != nil {
|
||||
return nil, entity.ErrUserExists
|
||||
}
|
||||
|
||||
// 哈希密码
|
||||
passwordHash, err := s.passwordHasher.Hash(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认生成占位邮箱,避免数据库约束失败
|
||||
email := username + "@local.ocdp"
|
||||
|
||||
// 创建用户
|
||||
user := entity.NewUser(username, passwordHash, email)
|
||||
user.ID = uuid.New().String()
|
||||
|
||||
if err := user.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(ctx context.Context, username, password string) (accessToken, refreshToken string, err error) {
|
||||
// 查找用户
|
||||
user, err := s.userRepo.GetByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if err := s.passwordHasher.Verify(password, user.PasswordHash); err != nil {
|
||||
return "", "", entity.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
accessToken, refreshToken, err = s.tokenGenerator.Generate(user.ID, user.Username)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
// RefreshToken 刷新 Token
|
||||
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
|
||||
return s.tokenGenerator.Refresh(refreshToken)
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 获取用户
|
||||
func (s *AuthService) GetUserByID(ctx context.Context, id string) (*entity.User, error) {
|
||||
return s.userRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// VerifyAccessToken 验证 Access Token(包括 revoked_after 检查)
|
||||
func (s *AuthService) VerifyAccessToken(ctx context.Context, token string) (userID, username string, err error) {
|
||||
// 1. JWT 自验证
|
||||
userID, username, issuedAt, err := s.tokenGenerator.VerifyWithIssuedAt(token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 2. 检查用户级别的撤销时间
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return "", "", entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 3. 如果 Token 签发时间早于 revoked_after,则失效
|
||||
if issuedAt < user.RevokedAfter.Unix() {
|
||||
return "", "", entity.ErrTokenRevoked
|
||||
}
|
||||
|
||||
return userID, username, nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(会触发全局登出)
|
||||
func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPassword, newPassword string) error {
|
||||
// 1. 获取用户
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 2. 验证旧密码
|
||||
if err := s.passwordHasher.Verify(oldPassword, user.PasswordHash); err != nil {
|
||||
return entity.ErrInvalidPassword
|
||||
}
|
||||
|
||||
// 3. 哈希新密码
|
||||
newPasswordHash, err := s.passwordHasher.Hash(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 更新密码(会自动触发 revoked_after 更新)
|
||||
user.UpdatePassword(newPasswordHash)
|
||||
|
||||
// 5. 保存到数据库
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
|
||||
// ForceLogoutAll 强制全局登出(管理员操作)
|
||||
func (s *AuthService) ForceLogoutAll(ctx context.Context, userID string) error {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return entity.ErrUserNotFound
|
||||
}
|
||||
|
||||
user.RevokeAllTokens()
|
||||
return s.userRepo.Update(ctx, user)
|
||||
}
|
||||
77
backend/internal/domain/service/cluster_service.go
Normal file
77
backend/internal/domain/service/cluster_service.go
Normal file
@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// ClusterService 集群管理领域服务
|
||||
type ClusterService struct {
|
||||
clusterRepo repository.ClusterRepository
|
||||
}
|
||||
|
||||
// NewClusterService 创建集群服务
|
||||
func NewClusterService(clusterRepo repository.ClusterRepository) *ClusterService {
|
||||
return &ClusterService{
|
||||
clusterRepo: clusterRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCluster 创建新集群
|
||||
func (s *ClusterService) CreateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||
// 生成 ID
|
||||
cluster.ID = uuid.New().String()
|
||||
|
||||
// 验证
|
||||
if err := cluster.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
existingCluster, _ := s.clusterRepo.GetByName(ctx, cluster.Name)
|
||||
if existingCluster != nil {
|
||||
return entity.ErrClusterExists
|
||||
}
|
||||
|
||||
return s.clusterRepo.Create(ctx, cluster)
|
||||
}
|
||||
|
||||
// GetCluster 获取集群
|
||||
func (s *ClusterService) GetCluster(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
return s.clusterRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateCluster 更新集群
|
||||
func (s *ClusterService) UpdateCluster(ctx context.Context, cluster *entity.Cluster) error {
|
||||
// 检查是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, cluster.ID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 验证
|
||||
if err := cluster.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.clusterRepo.Update(ctx, cluster)
|
||||
}
|
||||
|
||||
// DeleteCluster 删除集群
|
||||
func (s *ClusterService) DeleteCluster(ctx context.Context, id string) error {
|
||||
// 检查是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return s.clusterRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ListClusters 列出所有集群
|
||||
func (s *ClusterService) ListClusters(ctx context.Context) ([]*entity.Cluster, error) {
|
||||
return s.clusterRepo.List(ctx)
|
||||
}
|
||||
|
||||
456
backend/internal/domain/service/instance_service.go
Normal file
456
backend/internal/domain/service/instance_service.go
Normal file
@ -0,0 +1,456 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// InstanceService Helm 实例管理领域服务
|
||||
type InstanceService struct {
|
||||
instanceRepo repository.InstanceRepository
|
||||
clusterRepo repository.ClusterRepository
|
||||
registryRepo repository.RegistryRepository
|
||||
helmClient repository.HelmClient
|
||||
ociClient repository.OCIClient
|
||||
entryClient repository.InstanceEntryClient
|
||||
}
|
||||
|
||||
// NewInstanceService 创建实例服务
|
||||
func NewInstanceService(
|
||||
instanceRepo repository.InstanceRepository,
|
||||
clusterRepo repository.ClusterRepository,
|
||||
registryRepo repository.RegistryRepository,
|
||||
helmClient repository.HelmClient,
|
||||
ociClient repository.OCIClient,
|
||||
entryClient repository.InstanceEntryClient,
|
||||
) *InstanceService {
|
||||
return &InstanceService{
|
||||
instanceRepo: instanceRepo,
|
||||
clusterRepo: clusterRepo,
|
||||
registryRepo: registryRepo,
|
||||
helmClient: helmClient,
|
||||
ociClient: ociClient,
|
||||
entryClient: entryClient,
|
||||
}
|
||||
}
|
||||
|
||||
const chartCacheDir = "/tmp/charts"
|
||||
|
||||
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
||||
filename := fmt.Sprintf("%s-%s.tgz", instance.Chart, instance.Version)
|
||||
return filepath.Join(chartCacheDir, filename)
|
||||
}
|
||||
|
||||
func (s *InstanceService) downloadChart(ctx context.Context, registry *entity.Registry, instance *entity.Instance) error {
|
||||
if err := os.MkdirAll(chartCacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to ensure chart cache dir: %w", err)
|
||||
}
|
||||
chartPath := s.chartArchivePath(instance)
|
||||
if err := s.ociClient.PullArtifact(ctx, registry, instance.Repository, instance.Version, chartPath); err != nil {
|
||||
return fmt.Errorf("failed to download chart artifact: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateInstance 创建(安装)新实例
|
||||
func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||
// 生成 ID
|
||||
instance.ID = uuid.New().String()
|
||||
|
||||
// 验证
|
||||
if err := instance.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查集群是否存在
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 检查 Registry 是否存在
|
||||
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
// 检查实例是否已存在
|
||||
existingInstance, _ := s.instanceRepo.GetByClusterAndName(ctx, instance.ClusterID, instance.Name)
|
||||
if existingInstance != nil {
|
||||
return entity.ErrInstanceExists
|
||||
}
|
||||
|
||||
instance.BeginOperation(entity.OperationInstall, "Preparing installation")
|
||||
|
||||
// 先写入数据库,记录 pending 状态
|
||||
if err := s.instanceRepo.Create(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 下载 chart artifact 供 Helm 使用
|
||||
if err := s.downloadChart(ctx, registry, instance); err != nil {
|
||||
instance.MarkFailure("Failed to download chart", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步执行 Helm 安装并监控状态
|
||||
go s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance)
|
||||
|
||||
// 立即返回,状态同步由后台任务处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstance 获取实例
|
||||
func (s *InstanceService) GetInstance(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
return s.instanceRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// GetInstanceStatus 获取实例实时状态
|
||||
func (s *InstanceService) GetInstanceStatus(ctx context.Context, id string) (*entity.Instance, error) {
|
||||
// 从数据库获取基本信息
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 从 Helm 获取实时状态
|
||||
liveStatus, err := s.helmClient.GetStatus(ctx, cluster, instance.Name, instance.Namespace)
|
||||
if err != nil {
|
||||
return instance, err // 返回数据库中的信息,但标记错误
|
||||
}
|
||||
|
||||
// 合并实时状态
|
||||
instance.Status = liveStatus.Status
|
||||
instance.Revision = liveStatus.Revision
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// UpdateInstance 更新(升级)实例
|
||||
func (s *InstanceService) UpdateInstance(ctx context.Context, instance *entity.Instance) error {
|
||||
// 检查实例是否存在
|
||||
existingInstance, err := s.instanceRepo.GetByID(ctx, instance.ID)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, existingInstance.ClusterID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 获取 Registry 信息
|
||||
registry, err := s.registryRepo.GetByID(ctx, instance.RegistryID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
instance.BeginOperation(entity.OperationUpgrade, "Pending upgrade")
|
||||
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 下载所需 Chart
|
||||
if err := s.downloadChart(ctx, registry, instance); err != nil {
|
||||
instance.MarkFailure("Failed to download chart", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步执行 Helm 升级并监控状态
|
||||
go s.executeAndSyncUpgrade(context.Background(), instance.ID, cluster, registry, instance)
|
||||
|
||||
// 立即返回,状态同步由后台任务处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteInstance 删除(卸载)实例
|
||||
func (s *InstanceService) DeleteInstance(ctx context.Context, id string) error {
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
instance.BeginOperation(entity.OperationDelete, "Pending uninstall")
|
||||
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步执行 Helm 卸载并监控状态
|
||||
go s.executeAndSyncUninstall(context.Background(), instance.ID, cluster, instance.Name, instance.Namespace)
|
||||
|
||||
// 立即返回,状态同步由后台任务处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackInstance 回滚实例
|
||||
func (s *InstanceService) RollbackInstance(ctx context.Context, id string, revision int) error {
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
if err != nil {
|
||||
return entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
instance.BeginOperation(entity.OperationRollback, fmt.Sprintf("Rolling back to revision %d", revision))
|
||||
if err := s.instanceRepo.Update(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步执行 Helm 回滚并监控状态
|
||||
go s.executeAndSyncRollback(context.Background(), instance.ID, cluster, instance.Name, instance.Namespace, revision)
|
||||
|
||||
// 立即返回,状态同步由后台任务处理
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetInstanceHistory 获取实例历史
|
||||
func (s *InstanceService) GetInstanceHistory(ctx context.Context, id string) ([]*entity.ReleaseHistory, error) {
|
||||
// 检查实例是否存在
|
||||
instance, err := s.instanceRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
// 获取集群信息
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, instance.ClusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
// 从 Helm 获取历史
|
||||
return s.helmClient.GetHistory(ctx, cluster, instance.Name, instance.Namespace)
|
||||
}
|
||||
|
||||
// ListInstancesByCluster 列出集群的所有实例
|
||||
func (s *InstanceService) ListInstancesByCluster(ctx context.Context, clusterID string) ([]*entity.Instance, error) {
|
||||
// 检查集群是否存在
|
||||
_, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
return s.instanceRepo.ListByCluster(ctx, clusterID)
|
||||
}
|
||||
|
||||
// ListInstanceEntries 列出实例关联的入口信息(Service / Ingress)
|
||||
func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, instanceID string) ([]*entity.InstanceEntry, error) {
|
||||
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
if instance.ClusterID != clusterID {
|
||||
return nil, entity.ErrInstanceNotFound
|
||||
}
|
||||
|
||||
cluster, err := s.clusterRepo.GetByID(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
if s.entryClient == nil {
|
||||
return nil, fmt.Errorf("instance entry client is not configured")
|
||||
}
|
||||
|
||||
return s.entryClient.ListEntries(ctx, cluster, instance)
|
||||
}
|
||||
|
||||
// executeAndSyncInstall 异步执行安装并监控状态
|
||||
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||
// 执行 Helm 安装
|
||||
if err := s.helmClient.Install(ctx, cluster, instance); err != nil {
|
||||
// 更新实例状态为失败
|
||||
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if updateErr == nil && instance != nil {
|
||||
instance.MarkFailure("Helm install failed", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 安装成功后,同步状态
|
||||
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall)
|
||||
}
|
||||
|
||||
// executeAndSyncUpgrade 异步执行升级并监控状态
|
||||
func (s *InstanceService) executeAndSyncUpgrade(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
||||
// 执行 Helm 升级
|
||||
if err := s.helmClient.Upgrade(ctx, cluster, instance); err != nil {
|
||||
// 更新实例状态为失败
|
||||
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if updateErr == nil && instance != nil {
|
||||
instance.MarkFailure("Helm upgrade failed", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 升级成功后,同步状态
|
||||
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationUpgrade)
|
||||
}
|
||||
|
||||
// executeAndSyncRollback 异步执行回滚并监控状态
|
||||
func (s *InstanceService) executeAndSyncRollback(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, revision int) {
|
||||
// 执行 Helm 回滚
|
||||
if err := s.helmClient.Rollback(ctx, cluster, releaseName, namespace, revision); err != nil {
|
||||
// 更新实例状态为失败
|
||||
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if updateErr == nil && instance != nil {
|
||||
instance.MarkFailure("Helm rollback failed", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 回滚成功后,同步状态
|
||||
s.syncInstanceStatus(ctx, instanceID, cluster, releaseName, namespace, entity.OperationRollback)
|
||||
}
|
||||
|
||||
// executeAndSyncUninstall 异步执行卸载并监控状态
|
||||
func (s *InstanceService) executeAndSyncUninstall(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string) {
|
||||
// 执行 Helm 卸载
|
||||
err := s.helmClient.Uninstall(ctx, cluster, releaseName, namespace)
|
||||
|
||||
// 获取实例
|
||||
instance, getErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if getErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// 如果错误不是"未找到",则标记为失败
|
||||
if !errors.Is(err, entity.ErrInstanceNotFound) {
|
||||
instance.MarkFailure("Helm uninstall failed", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
} else {
|
||||
// 如果未找到,说明已经卸载,直接删除数据库记录
|
||||
_ = s.instanceRepo.Delete(ctx, instanceID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 卸载成功,标记为已卸载
|
||||
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Instance uninstalled successfully")
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
|
||||
// 验证卸载是否完成:尝试获取状态,如果获取不到说明已卸载
|
||||
time.Sleep(3 * time.Second)
|
||||
_, statusErr := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||
if statusErr != nil {
|
||||
// 无法获取状态,说明已卸载,删除数据库记录
|
||||
_ = s.instanceRepo.Delete(ctx, instanceID)
|
||||
} else {
|
||||
// 仍然可以获取状态,可能还在卸载中,继续等待
|
||||
// 设置状态为 uninstalled,但不删除记录,让用户手动删除或等待自动清理
|
||||
instance.MarkSuccess(entity.StatusUninstalled, instance.Revision, "Uninstall in progress")
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
}
|
||||
}
|
||||
|
||||
// syncInstanceStatus 同步实例状态(定期检查 Helm 状态并更新数据库)
|
||||
func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID string, cluster *entity.Cluster, releaseName, namespace string, operation entity.InstanceOperation) {
|
||||
maxAttempts := 30 // 最多尝试30次(约5分钟)
|
||||
interval := 10 * time.Second // 每10秒检查一次
|
||||
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
time.Sleep(interval)
|
||||
|
||||
// 获取数据库中的实例
|
||||
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if err != nil {
|
||||
// 实例不存在,停止同步
|
||||
return
|
||||
}
|
||||
|
||||
// 从 Helm 获取实时状态
|
||||
liveStatus, err := s.helmClient.GetStatus(ctx, cluster, releaseName, namespace)
|
||||
if err != nil {
|
||||
// 如果获取状态失败,可能是还在部署中,继续等待
|
||||
if i < maxAttempts-1 {
|
||||
continue
|
||||
}
|
||||
// 最后一次尝试失败,标记为失败
|
||||
instance.MarkFailure("Failed to get status from Helm", err)
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据操作类型和 Helm 状态更新实例状态
|
||||
shouldUpdate := false
|
||||
switch operation {
|
||||
case entity.OperationInstall:
|
||||
// 安装操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||
if liveStatus.Status == entity.StatusDeployed {
|
||||
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance deployed successfully")
|
||||
shouldUpdate = true
|
||||
} else if liveStatus.Status == entity.StatusFailed {
|
||||
instance.MarkFailure("Installation failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||
shouldUpdate = true
|
||||
}
|
||||
case entity.OperationUpgrade:
|
||||
// 升级操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||
if liveStatus.Status == entity.StatusDeployed {
|
||||
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance upgraded successfully")
|
||||
shouldUpdate = true
|
||||
} else if liveStatus.Status == entity.StatusFailed {
|
||||
instance.MarkFailure("Upgrade failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||
shouldUpdate = true
|
||||
}
|
||||
case entity.OperationRollback:
|
||||
// 回滚操作:如果 Helm 状态是 deployed,则更新为 deployed
|
||||
if liveStatus.Status == entity.StatusDeployed {
|
||||
instance.MarkSuccess(entity.StatusDeployed, liveStatus.Revision, "Instance rolled back successfully")
|
||||
shouldUpdate = true
|
||||
} else if liveStatus.Status == entity.StatusFailed {
|
||||
instance.MarkFailure("Rollback failed", fmt.Errorf("Helm status: %s", liveStatus.Status))
|
||||
shouldUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果状态已更新为最终状态,停止同步
|
||||
if shouldUpdate {
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果状态已经是最终状态(deployed 或 failed),停止同步
|
||||
if instance.Status == entity.StatusDeployed || instance.Status == entity.StatusFailed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 超时,标记为失败
|
||||
instance, err := s.instanceRepo.GetByID(ctx, instanceID)
|
||||
if err == nil && instance != nil {
|
||||
instance.MarkFailure("Operation timeout", fmt.Errorf("Status sync timeout after %d attempts", maxAttempts))
|
||||
_ = s.instanceRepo.Update(ctx, instance)
|
||||
}
|
||||
}
|
||||
111
backend/internal/domain/service/instance_service_test.go
Normal file
111
backend/internal/domain/service/instance_service_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
persistencemock "github.com/ocdp/cluster-service/internal/adapter/output/persistence/mock"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
func TestDeleteInstanceIgnoresMissingRelease(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
instanceRepo := persistencemock.NewInstanceRepositoryMock()
|
||||
|
||||
instance := &entity.Instance{
|
||||
ID: "inst-1",
|
||||
ClusterID: "cluster-1",
|
||||
Name: "demo",
|
||||
Namespace: "default",
|
||||
}
|
||||
if err := instanceRepo.Create(ctx, instance); err != nil {
|
||||
t.Fatalf("failed to seed instance: %v", err)
|
||||
}
|
||||
|
||||
cluster := &entity.Cluster{ID: "cluster-1", Name: "cluster", Host: "https://example.com"}
|
||||
clusterRepo := &stubClusterRepo{cluster: cluster}
|
||||
|
||||
svc := NewInstanceService(
|
||||
instanceRepo,
|
||||
clusterRepo,
|
||||
nil,
|
||||
&stubHelmClient{uninstallErr: entity.ErrInstanceNotFound},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
if err := svc.DeleteInstance(ctx, instance.ID); err != nil {
|
||||
t.Fatalf("DeleteInstance returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := instanceRepo.GetByID(ctx, instance.ID); !errors.Is(err, entity.ErrInstanceNotFound) {
|
||||
t.Fatalf("expected instance removed, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type stubClusterRepo struct {
|
||||
cluster *entity.Cluster
|
||||
}
|
||||
|
||||
func (s *stubClusterRepo) Create(ctx context.Context, cluster *entity.Cluster) error {
|
||||
s.cluster = cluster
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubClusterRepo) GetByID(ctx context.Context, id string) (*entity.Cluster, error) {
|
||||
if s.cluster != nil && s.cluster.ID == id {
|
||||
return s.cluster, nil
|
||||
}
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
func (*stubClusterRepo) GetByName(ctx context.Context, name string) (*entity.Cluster, error) {
|
||||
return nil, entity.ErrClusterNotFound
|
||||
}
|
||||
|
||||
func (*stubClusterRepo) Update(ctx context.Context, cluster *entity.Cluster) error { return nil }
|
||||
|
||||
func (*stubClusterRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||
|
||||
func (*stubClusterRepo) List(ctx context.Context) ([]*entity.Cluster, error) { return nil, nil }
|
||||
|
||||
type stubHelmClient struct {
|
||||
uninstallErr error
|
||||
}
|
||||
|
||||
func (*stubHelmClient) Install(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stubHelmClient) Upgrade(ctx context.Context, cluster *entity.Cluster, instance *entity.Instance) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubHelmClient) Uninstall(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) error {
|
||||
return s.uninstallErr
|
||||
}
|
||||
|
||||
func (*stubHelmClient) Rollback(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string, revision int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*stubHelmClient) GetStatus(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (*entity.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*stubHelmClient) GetHistory(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) ([]*entity.ReleaseHistory, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*stubHelmClient) List(ctx context.Context, cluster *entity.Cluster, namespace string) ([]*entity.Instance, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*stubHelmClient) GetValues(ctx context.Context, cluster *entity.Cluster, releaseName, namespace string) (map[string]interface{}, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var _ repository.ClusterRepository = (*stubClusterRepo)(nil)
|
||||
var _ repository.HelmClient = (*stubHelmClient)(nil)
|
||||
102
backend/internal/domain/service/monitoring_service.go
Normal file
102
backend/internal/domain/service/monitoring_service.go
Normal file
@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// MonitoringService 监控服务
|
||||
type MonitoringService struct {
|
||||
clusterRepo repository.ClusterRepository
|
||||
metricsClient repository.MetricsClient
|
||||
}
|
||||
|
||||
// NewMonitoringService 创建监控服务
|
||||
func NewMonitoringService(
|
||||
clusterRepo repository.ClusterRepository,
|
||||
metricsClient repository.MetricsClient,
|
||||
) *MonitoringService {
|
||||
return &MonitoringService{
|
||||
clusterRepo: clusterRepo,
|
||||
metricsClient: metricsClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetClusterMonitoring 获取单个集群的监控信息
|
||||
func (s *MonitoringService) GetClusterMonitoring(ctx context.Context, clusterID string) (*entity.ClusterMetrics, error) {
|
||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cluster metrics: %w", err)
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// ListClusterMonitoring 获取所有集群的监控信息
|
||||
func (s *MonitoringService) ListClusterMonitoring(ctx context.Context) ([]*entity.ClusterMetrics, error) {
|
||||
// 获取所有集群
|
||||
clusters, err := s.clusterRepo.List(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list clusters: %w", err)
|
||||
}
|
||||
|
||||
// 获取每个集群的监控数据
|
||||
result := make([]*entity.ClusterMetrics, 0, len(clusters))
|
||||
for _, cluster := range clusters {
|
||||
metrics, err := s.metricsClient.GetClusterMetrics(ctx, cluster.ID)
|
||||
if err != nil {
|
||||
// 如果某个集群获取失败,记录错误但继续
|
||||
fmt.Printf("Warning: failed to get metrics for cluster %s: %v\n", cluster.ID, err)
|
||||
// 返回基本信息
|
||||
metrics = &entity.ClusterMetrics{
|
||||
ClusterID: cluster.ID,
|
||||
ClusterName: cluster.Name,
|
||||
Status: "unknown",
|
||||
}
|
||||
}
|
||||
result = append(result, metrics)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetMonitoringSummary 获取监控汇总信息
|
||||
func (s *MonitoringService) GetMonitoringSummary(ctx context.Context) (*entity.MonitoringSummary, error) {
|
||||
// 获取所有集群监控数据
|
||||
monitoringList, err := s.ListClusterMonitoring(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list monitoring: %w", err)
|
||||
}
|
||||
|
||||
// 统计汇总
|
||||
summary := &entity.MonitoringSummary{
|
||||
TotalClusters: len(monitoringList),
|
||||
}
|
||||
|
||||
for _, m := range monitoringList {
|
||||
switch m.Status {
|
||||
case "healthy":
|
||||
summary.HealthyClusters++
|
||||
case "warning":
|
||||
summary.WarningClusters++
|
||||
case "error":
|
||||
summary.ErrorClusters++
|
||||
}
|
||||
summary.TotalNodes += m.NodeCount
|
||||
summary.TotalPods += m.PodCount
|
||||
}
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
// GetNodeMetrics 获取集群的节点指标
|
||||
func (s *MonitoringService) GetNodeMetrics(ctx context.Context, clusterID string) ([]*entity.NodeMetrics, error) {
|
||||
nodes, err := s.metricsClient.GetNodeMetrics(ctx, clusterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get node metrics: %w", err)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
92
backend/internal/domain/service/registry_service.go
Normal file
92
backend/internal/domain/service/registry_service.go
Normal file
@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||
)
|
||||
|
||||
// RegistryService Registry 管理领域服务
|
||||
type RegistryService struct {
|
||||
registryRepo repository.RegistryRepository
|
||||
ociClient repository.OCIClient
|
||||
}
|
||||
|
||||
// NewRegistryService 创建 Registry 服务
|
||||
func NewRegistryService(
|
||||
registryRepo repository.RegistryRepository,
|
||||
ociClient repository.OCIClient,
|
||||
) *RegistryService {
|
||||
return &RegistryService{
|
||||
registryRepo: registryRepo,
|
||||
ociClient: ociClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRegistry 创建新 Registry
|
||||
func (s *RegistryService) CreateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||
// 生成 ID
|
||||
registry.ID = uuid.New().String()
|
||||
|
||||
// 验证
|
||||
if err := registry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
existingRegistry, _ := s.registryRepo.GetByName(ctx, registry.Name)
|
||||
if existingRegistry != nil {
|
||||
return entity.ErrRegistryExists
|
||||
}
|
||||
|
||||
return s.registryRepo.Create(ctx, registry)
|
||||
}
|
||||
|
||||
// GetRegistry 获取 Registry
|
||||
func (s *RegistryService) GetRegistry(ctx context.Context, id string) (*entity.Registry, error) {
|
||||
return s.registryRepo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// UpdateRegistry 更新 Registry
|
||||
func (s *RegistryService) UpdateRegistry(ctx context.Context, registry *entity.Registry) error {
|
||||
// 检查是否存在
|
||||
_, err := s.registryRepo.GetByID(ctx, registry.ID)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
// 验证
|
||||
if err := registry.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.registryRepo.Update(ctx, registry)
|
||||
}
|
||||
|
||||
// DeleteRegistry 删除 Registry
|
||||
func (s *RegistryService) DeleteRegistry(ctx context.Context, id string) error {
|
||||
// 检查是否存在
|
||||
_, err := s.registryRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.registryRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// ListRegistries 列出所有 Registries
|
||||
func (s *RegistryService) ListRegistries(ctx context.Context) ([]*entity.Registry, error) {
|
||||
return s.registryRepo.List(ctx)
|
||||
}
|
||||
|
||||
// CheckHealth 检查 Registry 健康状态
|
||||
func (s *RegistryService) CheckHealth(ctx context.Context, id string) error {
|
||||
registry, err := s.registryRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return entity.ErrRegistryNotFound
|
||||
}
|
||||
|
||||
return s.ociClient.CheckHealth(ctx, registry)
|
||||
}
|
||||
|
||||
128
backend/internal/pkg/crypto/crypto.go
Normal file
128
backend/internal/pkg/crypto/crypto.go
Normal file
@ -0,0 +1,128 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Encryptor 加密器接口
|
||||
type Encryptor interface {
|
||||
Encrypt(plaintext string) (string, error)
|
||||
Decrypt(ciphertext string) (string, error)
|
||||
}
|
||||
|
||||
// AESEncryptor AES 加密器
|
||||
type AESEncryptor struct {
|
||||
key []byte
|
||||
}
|
||||
|
||||
// NewAESEncryptor 创建 AES 加密器
|
||||
// key: 加密密钥(会自动派生为32字节密钥)
|
||||
func NewAESEncryptor(key string) *AESEncryptor {
|
||||
// 使用 SHA256 派生固定长度的密钥
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return &AESEncryptor{
|
||||
key: hash[:],
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt 加密字符串
|
||||
// 返回 Base64 编码的密文
|
||||
func (e *AESEncryptor) Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 创建 GCM 模式
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 生成随机 nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 加密(nonce 会自动附加到密文前面)
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
|
||||
// Base64 编码
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt 解密字符串
|
||||
// 输入为 Base64 编码的密文
|
||||
func (e *AESEncryptor) Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Base64 解码
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(e.key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
// 提取 nonce 和实际密文
|
||||
nonce, cipherBytes := data[:nonceSize], data[nonceSize:]
|
||||
|
||||
// 解密
|
||||
plaintext, err := gcm.Open(nil, nonce, cipherBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// MaskSensitiveData 脱敏显示敏感数据
|
||||
// 如果数据为空或已加密标记,返回掩码
|
||||
func MaskSensitiveData(data string) string {
|
||||
if data == "" {
|
||||
return ""
|
||||
}
|
||||
return "••••••••" // 统一返回8个点,不泄露长度信息
|
||||
}
|
||||
|
||||
// IsEncrypted 检查字符串是否已加密
|
||||
// 简单检查:加密后的数据是 Base64 格式且长度较长
|
||||
func IsEncrypted(data string) bool {
|
||||
if data == "" {
|
||||
return false
|
||||
}
|
||||
// 加密后的数据至少有 nonce(12) + tag(16) + 内容,Base64后会更长
|
||||
if len(data) < 40 {
|
||||
return false
|
||||
}
|
||||
// 尝试 Base64 解码
|
||||
_, err := base64.StdEncoding.DecodeString(data)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
124
backend/internal/pkg/crypto/crypto_test.go
Normal file
124
backend/internal/pkg/crypto/crypto_test.go
Normal file
@ -0,0 +1,124 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAESEncryptor(t *testing.T) {
|
||||
encryptor := NewAESEncryptor("test-secret-key")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
plaintext string
|
||||
}{
|
||||
{"simple password", "password123"},
|
||||
{"harbor password", "BWGDIP@ssw0rd1401#"},
|
||||
{"empty string", ""},
|
||||
{"long certificate", "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pP"},
|
||||
{"unicode", "密码123!@#"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// 测试加密
|
||||
encrypted, err := encryptor.Encrypt(tt.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// 空字符串应该返回空
|
||||
if tt.plaintext == "" {
|
||||
if encrypted != "" {
|
||||
t.Errorf("Expected empty encrypted string, got %s", encrypted)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 加密后应该不同
|
||||
if encrypted == tt.plaintext {
|
||||
t.Errorf("Encrypted text should differ from plaintext")
|
||||
}
|
||||
|
||||
// 测试解密
|
||||
decrypted, err := encryptor.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// 解密后应该相同
|
||||
if decrypted != tt.plaintext {
|
||||
t.Errorf("Decrypted text mismatch: got %s, want %s", decrypted, tt.plaintext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskSensitiveData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"normal password", "password123", "••••••••"},
|
||||
{"empty string", "", ""},
|
||||
{"long string", "very-long-password-with-many-characters", "••••••••"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := MaskSensitiveData(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("MaskSensitiveData(%s) = %s, want %s", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsEncrypted(t *testing.T) {
|
||||
encryptor := NewAESEncryptor("test-key")
|
||||
|
||||
plaintext := "password123"
|
||||
encrypted, _ := encryptor.Encrypt(plaintext)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"encrypted data", encrypted, true},
|
||||
{"plaintext", "password123", false},
|
||||
{"empty string", "", false},
|
||||
{"short string", "abc", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := IsEncrypted(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsEncrypted(%s) = %v, want %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptionConsistency(t *testing.T) {
|
||||
encryptor := NewAESEncryptor("consistent-key")
|
||||
plaintext := "test-password"
|
||||
|
||||
// 多次加密同一内容,结果应该不同(因为使用随机 nonce)
|
||||
encrypted1, _ := encryptor.Encrypt(plaintext)
|
||||
encrypted2, _ := encryptor.Encrypt(plaintext)
|
||||
|
||||
if encrypted1 == encrypted2 {
|
||||
t.Error("Multiple encryptions of same plaintext should produce different ciphertexts")
|
||||
}
|
||||
|
||||
// 但解密结果应该相同
|
||||
decrypted1, _ := encryptor.Decrypt(encrypted1)
|
||||
decrypted2, _ := encryptor.Decrypt(encrypted2)
|
||||
|
||||
if decrypted1 != plaintext || decrypted2 != plaintext {
|
||||
t.Error("Decryption should produce original plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
123
backend/internal/pkg/jwt/jwt.go
Normal file
123
backend/internal/pkg/jwt/jwt.go
Normal file
@ -0,0 +1,123 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
AccessTokenDuration = 24 * time.Hour // Access Token 有效期
|
||||
RefreshTokenDuration = 7 * 24 * time.Hour // Refresh Token 有效期
|
||||
)
|
||||
|
||||
// JWTManager JWT 管理器
|
||||
type JWTManager struct {
|
||||
secretKey string
|
||||
}
|
||||
|
||||
// NewJWTManager 创建 JWT 管理器
|
||||
func NewJWTManager(secretKey string) *JWTManager {
|
||||
return &JWTManager{
|
||||
secretKey: secretKey,
|
||||
}
|
||||
}
|
||||
|
||||
// Claims JWT Claims
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Generate 生成 Access Token 和 Refresh Token
|
||||
func (m *JWTManager) Generate(userID, username string) (accessToken, refreshToken string, err error) {
|
||||
// 生成 Access Token
|
||||
accessClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
accessToken, err = accessTokenObj.SignedString([]byte(m.secretKey))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to sign access token: %w", err)
|
||||
}
|
||||
|
||||
// 生成 Refresh Token
|
||||
refreshClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(RefreshTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
refreshTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
|
||||
refreshToken, err = refreshTokenObj.SignedString([]byte(m.secretKey))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to sign refresh token: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
// Verify 验证 Token
|
||||
func (m *JWTManager) Verify(tokenString string) (userID, username string, err error) {
|
||||
userID, username, _, err = m.VerifyWithIssuedAt(tokenString)
|
||||
return userID, username, err
|
||||
}
|
||||
|
||||
// VerifyWithIssuedAt 验证 Token 并返回签发时间
|
||||
func (m *JWTManager) VerifyWithIssuedAt(tokenString string) (userID, username string, issuedAt int64, err error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(m.secretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", "", 0, fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims.UserID, claims.Username, claims.IssuedAt.Unix(), nil
|
||||
}
|
||||
|
||||
return "", "", 0, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Refresh 刷新 Token
|
||||
func (m *JWTManager) Refresh(refreshToken string) (string, error) {
|
||||
// 验证 Refresh Token
|
||||
userID, username, err := m.Verify(refreshToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
// 生成新的 Access Token
|
||||
accessClaims := &Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(AccessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
accessTokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
newAccessToken, err := accessTokenObj.SignedString([]byte(m.secretKey))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign new access token: %w", err)
|
||||
}
|
||||
|
||||
return newAccessToken, nil
|
||||
}
|
||||
|
||||
97
backend/internal/pkg/password/password.go
Normal file
97
backend/internal/pkg/password/password.go
Normal file
@ -0,0 +1,97 @@
|
||||
package password
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
// Argon2id 参数
|
||||
memory = 64 * 1024 // 64 MB
|
||||
iterations = 3
|
||||
parallelism = 2
|
||||
saltLength = 16
|
||||
keyLength = 32
|
||||
)
|
||||
|
||||
// Hasher 密码哈希器
|
||||
type Hasher struct{}
|
||||
|
||||
// NewHasher 创建密码哈希器
|
||||
func NewHasher() *Hasher {
|
||||
return &Hasher{}
|
||||
}
|
||||
|
||||
// Hash 哈希密码(使用 Argon2id)
|
||||
func (h *Hasher) Hash(password string) (string, error) {
|
||||
// 生成随机 salt
|
||||
salt := make([]byte, saltLength)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
// 使用 Argon2id 哈希密码
|
||||
hash := argon2.IDKey([]byte(password), salt, iterations, memory, parallelism, keyLength)
|
||||
|
||||
// 编码为字符串格式: $argon2id$v=19$m=65536,t=3,p=2$salt$hash
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, memory, iterations, parallelism, b64Salt, b64Hash)
|
||||
|
||||
return encodedHash, nil
|
||||
}
|
||||
|
||||
// Verify 验证密码
|
||||
func (h *Hasher) Verify(password, encodedHash string) error {
|
||||
// 解析编码的哈希
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return fmt.Errorf("invalid hash format")
|
||||
}
|
||||
|
||||
if parts[1] != "argon2id" {
|
||||
return fmt.Errorf("unsupported algorithm: %s", parts[1])
|
||||
}
|
||||
|
||||
// 解析参数
|
||||
var version int
|
||||
var m, t, p uint32
|
||||
_, err := fmt.Sscanf(parts[2], "v=%d", &version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse version: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse parameters: %w", err)
|
||||
}
|
||||
|
||||
// 解码 salt 和 hash
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode salt: %w", err)
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode hash: %w", err)
|
||||
}
|
||||
|
||||
// 使用相同参数哈希输入的密码
|
||||
computedHash := argon2.IDKey([]byte(password), salt, t, m, uint8(p), uint32(len(hash)))
|
||||
|
||||
// 使用常量时间比较防止时序攻击
|
||||
if subtle.ConstantTimeCompare(hash, computedHash) == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("password does not match")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user