refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics

- Add Workspace domain (entity, repository, service, handler, DTO)
- Add multi-tenant K8s client with tenant binding and quota management
- Add K8s diagnostics client (instance diagnostics)
- Add authorization middleware (authz package)
- Restructure frontend to feature-based architecture (features/)
- Add User Management page in configuration
- Add AccessDenied page and route guards
- Refactor shared components (form inputs, layout, UI)
- Update Tailwind config for new design system
- Add comprehensive documentation (docs/, tasks/, plans)
- Improve cluster service with better kubeconfig handling
- Add tests for crypto, config, helm client, tenant binding
This commit is contained in:
Ivan087
2026-05-12 16:15:14 +08:00
parent c5e51ed069
commit 7f238a3168
172 changed files with 15703 additions and 3162 deletions

View File

@ -6,9 +6,9 @@ type RepositoryListResponse struct {
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
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 响应(简化版本,只包含核心字段)
@ -23,11 +23,11 @@ type ArtifactResponse struct {
// 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
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)
Size int64 `json:"size"` // Artifact size (bytes)
}
// ArtifactListResponse Artifact 列表响应(包装格式,用于详细接口)
@ -42,3 +42,7 @@ type ValuesSchemaResponse struct {
Schema string `json:"schema"`
}
// ValuesYAMLResponse Helm Chart 默认 values.yaml 响应
type ValuesYAMLResponse struct {
ValuesYAML string `json:"valuesYaml"`
}

View File

@ -2,8 +2,18 @@ package dto
// RegisterRequest 用户注册请求
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role,omitempty"`
WorkspaceID string `json:"workspaceId,omitempty"`
Namespace string `json:"namespace,omitempty"`
DefaultClusterID string `json:"defaultClusterId,omitempty"`
QuotaCPU string `json:"quotaCpu,omitempty"`
QuotaMemory string `json:"quotaMemory,omitempty"`
QuotaGPU string `json:"quotaGpu,omitempty"`
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
}
// LoginRequest 用户登录请求
@ -19,17 +29,53 @@ type RefreshTokenRequest struct {
// AuthResponse 认证响应
type AuthResponse struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
UserID string `json:"userId"`
Username string `json:"username"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
UserID string `json:"userId"`
Username string `json:"username"`
Role string `json:"role"`
WorkspaceID string `json:"workspaceId"`
WorkspaceName string `json:"workspaceName,omitempty"`
Namespace string `json:"namespace,omitempty"`
DefaultClusterID string `json:"defaultClusterId,omitempty"`
QuotaCPU string `json:"quotaCpu,omitempty"`
QuotaMemory string `json:"quotaMemory,omitempty"`
QuotaGPU string `json:"quotaGpu,omitempty"`
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
Permissions []string `json:"permissions,omitempty"`
PermissionVersion int `json:"permissionVersion"`
}
// UserResponse 用户信息响应
type UserResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
WorkspaceID string `json:"workspaceId"`
WorkspaceName string `json:"workspaceName,omitempty"`
Namespace string `json:"namespace,omitempty"`
DefaultClusterID string `json:"defaultClusterId,omitempty"`
QuotaCPU string `json:"quotaCpu,omitempty"`
QuotaMemory string `json:"quotaMemory,omitempty"`
QuotaGPU string `json:"quotaGpu,omitempty"`
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
IsActive bool `json:"isActive"`
MustChangePassword bool `json:"mustChangePassword"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// UpdateUserRequest 管理员更新用户状态/角色请求
type UpdateUserRequest struct {
Role string `json:"role,omitempty"`
WorkspaceID string `json:"workspaceId,omitempty"`
Namespace string `json:"namespace,omitempty"`
DefaultClusterID string `json:"defaultClusterId,omitempty"`
QuotaCPU string `json:"quotaCpu,omitempty"`
QuotaMemory string `json:"quotaMemory,omitempty"`
QuotaGPU string `json:"quotaGpu,omitempty"`
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
IsActive *bool `json:"isActive,omitempty"`
MustChangePassword *bool `json:"mustChangePassword,omitempty"`
}

View File

@ -2,30 +2,38 @@ 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"`
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"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
DefaultNamespace string `json:"defaultNamespace"`
}
// 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"`
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"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
DefaultNamespace string `json:"defaultNamespace"`
}
// Normalize 将多种命名风格的字段合并到统一字段
@ -56,10 +64,15 @@ func (r *UpdateClusterRequest) Normalize() {
// ClusterResponse 集群响应(敏感数据已脱敏)
type ClusterResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
ID string `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Description string `json:"description"`
WorkspaceID string `json:"workspaceId"`
OwnerID string `json:"ownerId"`
Visibility string `json:"visibility"`
DefaultNamespace string `json:"defaultNamespace,omitempty"`
AllowedActions []string `json:"allowedActions,omitempty"`
// 认证配置状态(不返回实际证书数据,仅返回是否已配置)
HasCAData bool `json:"hasCaData"`
HasCertData bool `json:"hasCertData"`

View File

@ -9,6 +9,9 @@ import (
func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
response := &RegistryResponse{
ID: registry.ID,
WorkspaceID: registry.WorkspaceID,
OwnerID: registry.OwnerID,
Visibility: registry.Visibility,
Name: registry.Name,
URL: registry.URL,
Description: registry.Description,
@ -17,33 +20,37 @@ func ToRegistryResponse(registry *entity.Registry) *RegistryResponse {
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"),
ID: cluster.ID,
WorkspaceID: cluster.WorkspaceID,
OwnerID: cluster.OwnerID,
Visibility: cluster.Visibility,
Name: cluster.Name,
Host: cluster.Host,
Description: cluster.Description,
DefaultNamespace: cluster.DefaultNamespace,
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)
@ -57,7 +64,6 @@ func ToClusterResponse(cluster *entity.Cluster) *ClusterResponse {
if cluster.Token != "" {
response.Token = crypto.MaskSensitiveData(cluster.Token)
}
return response
}

View File

@ -12,4 +12,3 @@ type SuccessResponse struct {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}

View File

@ -43,23 +43,26 @@ type DeleteInstanceRequest struct {
// 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"`
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"`
WorkspaceID string `json:"workspaceId"`
OwnerID string `json:"ownerId"`
AllowedActions []string `json:"allowedActions,omitempty"`
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 实例状态响应
@ -131,3 +134,70 @@ type InstanceEntryResponse struct {
Hosts []InstanceEntryHostResponse `json:"hosts,omitempty"`
TLS []InstanceEntryTLSResponse `json:"tls,omitempty"`
}
type InstanceDiagnosticsResponse struct {
InstanceName string `json:"instanceName"`
Namespace string `json:"namespace"`
Pods []InstancePodDiagnostics `json:"pods"`
Services []InstanceServiceDiagnostics `json:"services"`
Events []InstanceEventDiagnostics `json:"events"`
Logs []InstancePodLogResponse `json:"logs"`
CollectedAt string `json:"collectedAt"`
}
type InstancePodDiagnostics struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Phase string `json:"phase"`
NodeName string `json:"nodeName,omitempty"`
PodIP string `json:"podIp,omitempty"`
HostIP string `json:"hostIp,omitempty"`
RestartCount int32 `json:"restartCount"`
Containers []InstanceContainerDiagnostics `json:"containers"`
Conditions []InstanceConditionDiagnostics `json:"conditions"`
CreationTimestamp string `json:"creationTimestamp,omitempty"`
}
type InstanceContainerDiagnostics struct {
Name string `json:"name"`
Image string `json:"image"`
Ready bool `json:"ready"`
RestartCount int32 `json:"restartCount"`
State string `json:"state"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
type InstanceConditionDiagnostics struct {
Type string `json:"type"`
Status string `json:"status"`
Reason string `json:"reason,omitempty"`
Message string `json:"message,omitempty"`
}
type InstanceServiceDiagnostics struct {
Name string `json:"name"`
Namespace string `json:"namespace"`
Type string `json:"type"`
ClusterIP string `json:"clusterIP,omitempty"`
Ports []InstanceEntryPortResponse `json:"ports,omitempty"`
}
type InstanceEventDiagnostics struct {
Type string `json:"type"`
Reason string `json:"reason"`
Message string `json:"message"`
InvolvedKind string `json:"involvedKind"`
InvolvedName string `json:"involvedName"`
Count int32 `json:"count"`
FirstTimestamp string `json:"firstTimestamp,omitempty"`
LastTimestamp string `json:"lastTimestamp,omitempty"`
}
type InstancePodLogResponse struct {
Pod string `json:"pod"`
Container string `json:"container"`
TailLines int64 `json:"tailLines"`
Log string `json:"log,omitempty"`
Error string `json:"error,omitempty"`
}

View File

@ -8,29 +8,29 @@ import (
// 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"`
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 节点监控响应
@ -72,28 +72,28 @@ type MonitoringSummaryResponse struct {
// 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,
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 {
@ -140,4 +140,3 @@ func ToMonitoringSummaryResponse(s *entity.MonitoringSummary) *MonitoringSummary
LastUpdate: s.LastUpdate,
}
}

View File

@ -2,36 +2,46 @@ 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"`
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"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
}
// 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"`
Name string `json:"name"`
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
Description string `json:"description"`
Insecure bool `json:"insecure"`
Visibility string `json:"visibility"`
GlobalShared bool `json:"globalShared"`
GlobalSharedAlt bool `json:"global_shared"`
}
// 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"`
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Description string `json:"description"`
WorkspaceID string `json:"workspaceId"`
OwnerID string `json:"ownerId"`
Visibility string `json:"visibility"`
AllowedActions []string `json:"allowedActions,omitempty"`
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 健康状态响应
@ -39,4 +49,3 @@ type RegistryHealthResponse struct {
Healthy bool `json:"healthy"`
Message string `json:"message,omitempty"`
}

View File

@ -29,14 +29,19 @@ func NewArtifactHandler(artifactService *service.ArtifactService) *ArtifactHandl
// @Accept json
// @Produce json
// @Param registry_id path string true "Registry ID"
// @Param artifact_type query string false "Artifact type filter (chart, all)" default(chart)
// @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"]
artifactType := r.URL.Query().Get("artifact_type")
if artifactType == "" {
artifactType = "chart"
}
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID)
repositories, err := h.artifactService.ListRepositories(r.Context(), registryID, artifactType)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list repositories", err.Error())
return
@ -50,13 +55,17 @@ func (h *ArtifactHandler) ListRepositories(w http.ResponseWriter, r *http.Reques
}
// Determine source and message based on repository count
source := "catalog"
source := "harbor-api"
catalogSupported := true
message := ""
if len(repositories) == 0 {
source = "unavailable"
message = "No repositories found in this registry"
if artifactType == "chart" {
message = "No chart repositories found in this registry"
} else {
message = "No repositories found in this registry"
}
}
response := &dto.RepositoryListResponse{
@ -191,3 +200,37 @@ func (h *ArtifactHandler) GetArtifactValuesSchema(w http.ResponseWriter, r *http
respondJSON(w, http.StatusOK, response)
}
// GetArtifactValuesYAML 获取 Helm Chart 的默认 values.yaml
// @Summary 获取 Helm Chart 默认 Values YAML
// @Description 获取 Helm Chart 包内原始 values.yaml用于高级覆盖编辑
// @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.ValuesYAMLResponse
// @Failure 500 {object} dto.ErrorResponse
// @Router /registries/{registry_id}/repositories/{repository_name}/artifacts/{reference}/values-yaml [get]
func (h *ArtifactHandler) GetArtifactValuesYAML(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
registryID := vars["registry_id"]
repositoryName := vars["repository_name"]
reference := vars["reference"]
valuesYAML, err := h.artifactService.GetValuesYAML(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):
respondError(w, http.StatusNotFound, "Values YAML not found", err.Error())
default:
respondError(w, http.StatusInternalServerError, "Failed to get values YAML", err.Error())
}
return
}
respondJSON(w, http.StatusOK, &dto.ValuesYAMLResponse{ValuesYAML: valuesYAML})
}

View File

@ -1,11 +1,16 @@
package rest
import (
"context"
"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"
"github.com/ocdp/cluster-service/internal/pkg/authz"
)
// AuthHandler 认证 Handler
@ -20,9 +25,9 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler {
}
}
// Register 用户注册
// @Summary 用户注册
// @Description 创建一个新的后台用户
// Register 管理员创建用户
// @Summary 管理员创建用户
// @Description 创建一个新的后台用户。公开自注册已禁用,只允许 admin 调用。
// @Tags Auth
// @Accept json
// @Produce json
@ -38,22 +43,64 @@ func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
}
// 调用领域服务
user, err := h.authService.Register(r.Context(), req.Username, req.Password)
user, err := h.authService.Register(r.Context(), req.Username, req.Password, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
Namespace: req.Namespace,
DefaultClusterID: req.DefaultClusterID,
QuotaCPU: req.QuotaCPU,
QuotaMemory: req.QuotaMemory,
QuotaGPU: req.QuotaGPU,
QuotaGPUMem: req.QuotaGPUMem,
}, req.IsActive, req.MustChangePassword)
if err != nil {
respondError(w, http.StatusBadRequest, "Registration failed", err.Error())
respondServiceError(w, err, "Registration failed")
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, h.convertUserResponse(r.Context(), user))
}
respondJSON(w, http.StatusCreated, response)
func (h *AuthHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.authService.ListUsers(r.Context())
if err != nil {
respondServiceError(w, err, "Failed to list users")
return
}
responses := make([]*dto.UserResponse, 0, len(users))
for _, user := range users {
responses = append(responses, h.convertUserResponse(r.Context(), user))
}
respondJSON(w, http.StatusOK, responses)
}
func (h *AuthHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
userID := mux.Vars(r)["user_id"]
var req dto.UpdateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
user, err := h.authService.UpdateUser(r.Context(), userID, req.Role, req.WorkspaceID, service.UserWorkspaceOptions{
Namespace: req.Namespace,
DefaultClusterID: req.DefaultClusterID,
QuotaCPU: req.QuotaCPU,
QuotaMemory: req.QuotaMemory,
QuotaGPU: req.QuotaGPU,
QuotaGPUMem: req.QuotaGPUMem,
}, req.IsActive, req.MustChangePassword)
if err != nil {
respondServiceError(w, err, "Failed to update user")
return
}
respondJSON(w, http.StatusOK, h.convertUserResponse(r.Context(), user))
}
func (h *AuthHandler) DeleteUser(w http.ResponseWriter, r *http.Request) {
userID := mux.Vars(r)["user_id"]
if err := h.authService.DeleteUser(r.Context(), userID); err != nil {
respondServiceError(w, err, "Failed to delete user")
return
}
w.WriteHeader(http.StatusNoContent)
}
// Login 用户登录
@ -74,25 +121,58 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
}
// 调用领域服务
accessToken, refreshToken, err := h.authService.Login(r.Context(), req.Username, req.Password)
accessToken, refreshToken, user, err := h.authService.Login(r.Context(), req.Username, req.Password)
if err != nil {
respondError(w, http.StatusUnauthorized, "Login failed", err.Error())
return
}
// 获取用户信息
// TODO: 从 token 解析用户信息或从服务获取
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
// 返回响应
response := &dto.AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
Username: req.Username,
AccessToken: accessToken,
RefreshToken: refreshToken,
UserID: user.ID,
Username: user.Username,
Role: user.Role,
WorkspaceID: user.WorkspaceID,
WorkspaceName: workspaceName(workspace),
Namespace: workspaceNamespace(workspace),
DefaultClusterID: workspaceDefaultClusterID(workspace),
QuotaCPU: workspaceQuotaCPU(workspace),
QuotaMemory: workspaceQuotaMemory(workspace),
QuotaGPU: workspaceQuotaGPU(workspace),
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
Permissions: authz.PermissionsForRole(user.Role),
PermissionVersion: 1,
}
respondJSON(w, http.StatusOK, response)
}
func (h *AuthHandler) convertUserResponse(ctx context.Context, user *entity.User) *dto.UserResponse {
workspace, _ := h.authService.GetWorkspaceByID(ctx, user.WorkspaceID)
return &dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: user.Role,
WorkspaceID: user.WorkspaceID,
WorkspaceName: workspaceName(workspace),
Namespace: workspaceNamespace(workspace),
DefaultClusterID: workspaceDefaultClusterID(workspace),
QuotaCPU: workspaceQuotaCPU(workspace),
QuotaMemory: workspaceQuotaMemory(workspace),
QuotaGPU: workspaceQuotaGPU(workspace),
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
IsActive: user.IsActive,
MustChangePassword: user.MustChangePassword,
CreatedAt: user.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: user.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
}
// RefreshToken 刷新 Token
// @Summary 刷新访问令牌
// @Description 使用刷新令牌获取新的访问令牌
@ -111,17 +191,109 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
}
// 调用领域服务
newAccessToken, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
newAccessToken, user, err := h.authService.RefreshToken(r.Context(), req.RefreshToken)
if err != nil {
respondError(w, http.StatusUnauthorized, "Token refresh failed", err.Error())
return
}
workspace, _ := h.authService.GetWorkspaceByID(r.Context(), user.WorkspaceID)
// 返回响应
response := &dto.AuthResponse{
AccessToken: newAccessToken,
RefreshToken: req.RefreshToken,
AccessToken: newAccessToken,
RefreshToken: req.RefreshToken,
UserID: user.ID,
Username: user.Username,
Role: user.Role,
WorkspaceID: user.WorkspaceID,
WorkspaceName: workspaceName(workspace),
Namespace: workspaceNamespace(workspace),
DefaultClusterID: workspaceDefaultClusterID(workspace),
QuotaCPU: workspaceQuotaCPU(workspace),
QuotaMemory: workspaceQuotaMemory(workspace),
QuotaGPU: workspaceQuotaGPU(workspace),
QuotaGPUMem: workspaceQuotaGPUMem(workspace),
Permissions: authz.PermissionsForRole(user.Role),
PermissionVersion: 1,
}
respondJSON(w, http.StatusOK, response)
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer "))
if token == "" || token == header {
respondError(w, http.StatusUnauthorized, "Unauthorized", "missing bearer token")
return
}
principal, err := h.authService.VerifyAccessToken(r.Context(), token)
if err != nil {
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
return
}
respondJSON(w, http.StatusOK, &dto.AuthResponse{
UserID: principal.UserID,
Username: principal.Username,
Role: principal.Role,
WorkspaceID: principal.WorkspaceID,
WorkspaceName: principal.WorkspaceName,
Namespace: principal.Namespace,
DefaultClusterID: principal.DefaultClusterID,
QuotaCPU: principal.QuotaCPU,
QuotaMemory: principal.QuotaMemory,
QuotaGPU: principal.QuotaGPU,
QuotaGPUMem: principal.QuotaGPUMem,
Permissions: principal.Permissions,
PermissionVersion: principal.PermissionVersion,
})
}
func workspaceName(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.Name
}
func workspaceNamespace(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.K8sNamespace
}
func workspaceDefaultClusterID(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.DefaultClusterID
}
func workspaceQuotaCPU(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.QuotaCPU
}
func workspaceQuotaMemory(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.QuotaMemory
}
func workspaceQuotaGPU(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.QuotaGPU
}
func workspaceQuotaGPUMem(workspace *entity.Workspace) string {
if workspace == nil {
return ""
}
return workspace.QuotaGPUMem
}

View File

@ -45,6 +45,11 @@ func (h *ClusterHandler) CreateCluster(w http.ResponseWriter, r *http.Request) {
// 创建实体
cluster := entity.NewCluster(req.Name, req.Host)
cluster.Description = req.Description
cluster.Visibility = req.Visibility
if req.GlobalShared || req.GlobalSharedAlt {
cluster.Visibility = "global_shared"
}
cluster.DefaultNamespace = req.DefaultNamespace
if req.CertData != "" && req.KeyData != "" {
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)
@ -147,6 +152,15 @@ func (h *ClusterHandler) UpdateCluster(w http.ResponseWriter, r *http.Request) {
// 更新字段
cluster.Update(req.Name, req.Host, req.Description)
if req.Visibility != "" {
cluster.Visibility = req.Visibility
}
if req.GlobalShared || req.GlobalSharedAlt {
cluster.Visibility = "global_shared"
}
if req.DefaultNamespace != "" {
cluster.DefaultNamespace = req.DefaultNamespace
}
if req.CertData != "" && req.KeyData != "" {
cluster.SetCertAuth(req.CAData, req.CertData, req.KeyData)

View File

@ -2,13 +2,17 @@ package rest
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"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"
"gopkg.in/yaml.v3"
)
// InstanceHandler 实例 Handler
@ -69,6 +73,14 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
}
if req.ValuesYAML != "" {
instance.SetValuesYAML(req.ValuesYAML)
if req.Values == nil {
values, err := parseValuesYAML(req.ValuesYAML)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
return
}
instance.SetValues(values)
}
}
// 调用领域服务
@ -77,28 +89,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
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)
respondJSON(w, http.StatusCreated, convertInstanceResponse(instance, true))
}
// GetInstance 获取实例详情
@ -113,6 +104,7 @@ func (h *InstanceHandler) CreateInstance(w http.ResponseWriter, r *http.Request)
// @Router /clusters/{cluster_id}/instances/{instance_id} [get]
func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
clusterID := vars["cluster_id"]
instanceID := vars["instance_id"]
instance, err := h.instanceService.GetInstance(r.Context(), instanceID)
@ -120,28 +112,12 @@ func (h *InstanceHandler) GetInstance(w http.ResponseWriter, r *http.Request) {
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"),
if instance.ClusterID != clusterID {
respondError(w, http.StatusNotFound, "Instance not found", "resource does not belong to cluster")
return
}
respondJSON(w, http.StatusOK, response)
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
}
// ListInstances 列出集群的所有实例
@ -159,30 +135,13 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
instances, err := h.instanceService.ListInstancesByCluster(r.Context(), clusterID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list instances", err.Error())
respondServiceError(w, err, "Failed to list instances")
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"),
})
responses = append(responses, convertInstanceResponse(instance, false))
}
response := &dto.InstanceListResponse{
@ -225,12 +184,22 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
// 更新字段
if req.Version != "" {
instance.Upgrade(req.Version, req.Values)
} else if req.Values != nil {
instance.SetValues(req.Values)
}
if req.Description != "" {
instance.Description = req.Description
}
if req.ValuesYAML != "" {
instance.SetValuesYAML(req.ValuesYAML)
if req.Values == nil {
values, err := parseValuesYAML(req.ValuesYAML)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid values YAML", err.Error())
return
}
instance.SetValues(values)
}
}
// 调用领域服务
@ -239,27 +208,7 @@ func (h *InstanceHandler) UpdateInstance(w http.ResponseWriter, r *http.Request)
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)
respondJSON(w, http.StatusOK, convertInstanceResponse(instance, true))
}
// DeleteInstance 删除实例
@ -320,6 +269,35 @@ func (h *InstanceHandler) ListInstanceEntries(w http.ResponseWriter, r *http.Req
respondJSON(w, http.StatusOK, responses)
}
func (h *InstanceHandler) GetInstanceDiagnostics(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
clusterID := vars["cluster_id"]
instanceID := vars["instance_id"]
tailLines := int64(200)
if raw := strings.TrimSpace(r.URL.Query().Get("tailLines")); raw != "" {
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil || parsed < 0 {
respondError(w, http.StatusBadRequest, "Invalid tailLines", "tailLines must be a positive integer")
return
}
tailLines = parsed
}
diagnostics, err := h.instanceService.GetInstanceDiagnostics(r.Context(), clusterID, instanceID, tailLines)
if err != nil {
status := http.StatusInternalServerError
switch err {
case entity.ErrInstanceNotFound, entity.ErrClusterNotFound:
status = http.StatusNotFound
case entity.ErrForbidden:
status = http.StatusForbidden
}
respondError(w, status, "Failed to collect instance diagnostics", err.Error())
return
}
respondJSON(w, http.StatusOK, convertInstanceDiagnostics(diagnostics))
}
func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryResponse {
portResponses := make([]dto.InstanceEntryPortResponse, 0, len(entry.Ports))
for _, port := range entry.Ports {
@ -369,3 +347,195 @@ func convertInstanceEntry(entry *entity.InstanceEntry) *dto.InstanceEntryRespons
TLS: tlsResponses,
}
}
func convertInstanceDiagnostics(diagnostics *entity.InstanceDiagnostics) *dto.InstanceDiagnosticsResponse {
if diagnostics == nil {
return &dto.InstanceDiagnosticsResponse{}
}
pods := make([]dto.InstancePodDiagnostics, 0, len(diagnostics.Pods))
for _, pod := range diagnostics.Pods {
containers := make([]dto.InstanceContainerDiagnostics, 0, len(pod.Containers))
for _, container := range pod.Containers {
containers = append(containers, dto.InstanceContainerDiagnostics{
Name: container.Name,
Image: container.Image,
Ready: container.Ready,
RestartCount: container.RestartCount,
State: container.State,
Reason: container.Reason,
Message: container.Message,
})
}
conditions := make([]dto.InstanceConditionDiagnostics, 0, len(pod.Conditions))
for _, condition := range pod.Conditions {
conditions = append(conditions, dto.InstanceConditionDiagnostics{
Type: condition.Type,
Status: condition.Status,
Reason: condition.Reason,
Message: condition.Message,
})
}
pods = append(pods, dto.InstancePodDiagnostics{
Name: pod.Name,
Namespace: pod.Namespace,
Phase: pod.Phase,
NodeName: pod.NodeName,
PodIP: pod.PodIP,
HostIP: pod.HostIP,
RestartCount: pod.RestartCount,
Containers: containers,
Conditions: conditions,
CreationTimestamp: formatTime(pod.CreationTimestamp),
})
}
services := make([]dto.InstanceServiceDiagnostics, 0, len(diagnostics.Services))
for _, svc := range diagnostics.Services {
ports := make([]dto.InstanceEntryPortResponse, 0, len(svc.Ports))
for _, port := range svc.Ports {
ports = append(ports, dto.InstanceEntryPortResponse{
Name: port.Name,
Protocol: port.Protocol,
Port: port.Port,
TargetPort: port.TargetPort,
NodePort: port.NodePort,
})
}
services = append(services, dto.InstanceServiceDiagnostics{
Name: svc.Name,
Namespace: svc.Namespace,
Type: svc.Type,
ClusterIP: svc.ClusterIP,
Ports: ports,
})
}
events := make([]dto.InstanceEventDiagnostics, 0, len(diagnostics.Events))
for _, event := range diagnostics.Events {
events = append(events, dto.InstanceEventDiagnostics{
Type: event.Type,
Reason: event.Reason,
Message: event.Message,
InvolvedKind: event.InvolvedKind,
InvolvedName: event.InvolvedName,
Count: event.Count,
FirstTimestamp: formatTime(event.FirstTimestamp),
LastTimestamp: formatTime(event.LastTimestamp),
})
}
logs := make([]dto.InstancePodLogResponse, 0, len(diagnostics.Logs))
for _, logEntry := range diagnostics.Logs {
logs = append(logs, dto.InstancePodLogResponse{
Pod: logEntry.Pod,
Container: logEntry.Container,
TailLines: logEntry.TailLines,
Log: logEntry.Log,
Error: logEntry.Error,
})
}
return &dto.InstanceDiagnosticsResponse{
InstanceName: diagnostics.InstanceName,
Namespace: diagnostics.Namespace,
Pods: pods,
Services: services,
Events: events,
Logs: logs,
CollectedAt: formatTime(diagnostics.CollectedAt),
}
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format(time.RFC3339)
}
func convertInstanceResponse(instance *entity.Instance, includeValues bool) *dto.InstanceResponse {
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),
WorkspaceID: instance.WorkspaceID,
OwnerID: instance.OwnerID,
StatusReason: instance.StatusReason,
LastOperation: string(instance.LastOperation),
LastError: instance.LastError,
Revision: instance.Revision,
AllowedActions: []string{"view", "update", "delete"},
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if includeValues {
response.Values = instance.Values
}
return response
}
func parseValuesYAML(valuesYAML string) (map[string]interface{}, error) {
valuesYAML = strings.TrimSpace(valuesYAML)
if valuesYAML == "" {
return map[string]interface{}{}, nil
}
var decoded interface{}
if err := yaml.Unmarshal([]byte(valuesYAML), &decoded); err != nil {
return nil, err
}
normalized, err := normalizeYAMLValue(decoded)
if err != nil {
return nil, err
}
values, ok := normalized.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("values YAML must be a mapping at the top level")
}
return values, nil
}
func normalizeYAMLValue(value interface{}) (interface{}, error) {
switch typed := value.(type) {
case map[string]interface{}:
normalized := make(map[string]interface{}, len(typed))
for key, child := range typed {
normalizedChild, err := normalizeYAMLValue(child)
if err != nil {
return nil, err
}
normalized[key] = normalizedChild
}
return normalized, nil
case map[interface{}]interface{}:
normalized := make(map[string]interface{}, len(typed))
for key, child := range typed {
keyString, ok := key.(string)
if !ok {
return nil, fmt.Errorf("values YAML contains non-string key %v", key)
}
normalizedChild, err := normalizeYAMLValue(child)
if err != nil {
return nil, err
}
normalized[keyString] = normalizedChild
}
return normalized, nil
case []interface{}:
normalized := make([]interface{}, 0, len(typed))
for _, child := range typed {
normalizedChild, err := normalizeYAMLValue(child)
if err != nil {
return nil, err
}
normalized = append(normalized, normalizedChild)
}
return normalized, nil
default:
return typed, nil
}
}

View File

@ -44,6 +44,10 @@ func (h *RegistryHandler) CreateRegistry(w http.ResponseWriter, r *http.Request)
registry := entity.NewRegistry(req.Name, req.URL)
registry.Description = req.Description
registry.Insecure = req.Insecure
registry.Visibility = req.Visibility
if req.GlobalShared || req.GlobalSharedAlt {
registry.Visibility = "global_shared"
}
registry.SetCredentials(req.Username, req.Password)
// 调用领域服务
@ -136,6 +140,12 @@ func (h *RegistryHandler) UpdateRegistry(w http.ResponseWriter, r *http.Request)
// 更新字段
registry.Update(req.Name, req.URL, req.Description)
registry.Insecure = req.Insecure
if req.Visibility != "" {
registry.Visibility = req.Visibility
}
if req.GlobalShared || req.GlobalSharedAlt {
registry.Visibility = "global_shared"
}
if req.Username != "" || req.Password != "" {
registry.SetCredentials(req.Username, req.Password)
}

View File

@ -3,7 +3,7 @@ package rest
import (
"encoding/json"
"net/http"
"github.com/ocdp/cluster-service/internal/adapter/input/http/dto"
)
@ -32,4 +32,3 @@ func respondSuccess(w http.ResponseWriter, message string, data interface{}) {
}
respondJSON(w, http.StatusOK, response)
}

View File

@ -0,0 +1,165 @@
package rest
import (
"encoding/json"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/ocdp/cluster-service/internal/domain/entity"
"github.com/ocdp/cluster-service/internal/domain/service"
"github.com/ocdp/cluster-service/internal/pkg/authz"
)
type WorkspaceHandler struct {
workspaceService *service.WorkspaceService
}
func NewWorkspaceHandler(workspaceService *service.WorkspaceService) *WorkspaceHandler {
return &WorkspaceHandler{workspaceService: workspaceService}
}
type createWorkspaceRequest struct {
Name string `json:"name"`
}
type workspaceResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
K8sNamespace string `json:"k8sNamespace"`
K8sSAName string `json:"k8sSaName"`
DefaultClusterID string `json:"defaultClusterId,omitempty"`
QuotaCPU string `json:"quotaCpu,omitempty"`
QuotaMemory string `json:"quotaMemory,omitempty"`
QuotaGPU string `json:"quotaGpu,omitempty"`
QuotaGPUMem string `json:"quotaGpuMemory,omitempty"`
CreatedBy string `json:"createdBy"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type bindClusterRequest struct {
ClusterID string `json:"clusterId"`
}
type kubeconfigRequest struct {
ClusterID string `json:"clusterId"`
TTLSeconds int64 `json:"ttlSeconds"`
}
func (h *WorkspaceHandler) ListWorkspaces(w http.ResponseWriter, r *http.Request) {
workspaces, err := h.workspaceService.ListWorkspaces(r.Context())
if err != nil {
respondServiceError(w, err, "Failed to list workspaces")
return
}
response := make([]workspaceResponse, 0, len(workspaces))
for _, workspace := range workspaces {
response = append(response, toWorkspaceResponse(workspace))
}
respondJSON(w, http.StatusOK, response)
}
func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Request) {
var req createWorkspaceRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
workspace, err := h.workspaceService.CreateWorkspace(r.Context(), req.Name)
if err != nil {
respondServiceError(w, err, "Failed to create workspace")
return
}
respondJSON(w, http.StatusCreated, toWorkspaceResponse(workspace))
}
func (h *WorkspaceHandler) InitClusterBinding(w http.ResponseWriter, r *http.Request) {
workspaceID := mux.Vars(r)["workspace_id"]
var req bindClusterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
binding, err := h.workspaceService.EnsureClusterBinding(r.Context(), workspaceID, req.ClusterID)
if err != nil {
respondServiceError(w, err, "Failed to initialize workspace cluster binding")
return
}
respondJSON(w, http.StatusOK, binding)
}
func (h *WorkspaceHandler) IssueKubeconfig(w http.ResponseWriter, r *http.Request) {
workspaceID := mux.Vars(r)["workspace_id"]
var req kubeconfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body", err.Error())
return
}
kubeconfig, err := h.workspaceService.IssueKubeconfig(r.Context(), workspaceID, req.ClusterID, time.Duration(req.TTLSeconds)*time.Second)
if err != nil {
respondServiceError(w, err, "Failed to issue kubeconfig")
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"kubeconfig": kubeconfig.Kubeconfig,
"expiresAt": kubeconfig.ExpiresAt.Format(time.RFC3339),
})
}
func (h *WorkspaceHandler) IssueCurrentKubeconfig(w http.ResponseWriter, r *http.Request) {
clusterID := r.URL.Query().Get("clusterId")
if clusterID == "" {
clusterID = r.URL.Query().Get("cluster_id")
}
kubeconfig, err := h.workspaceService.IssueCurrentKubeconfig(r.Context(), clusterID, 2*time.Hour)
if err != nil {
respondServiceError(w, err, "Failed to issue kubeconfig")
return
}
w.Header().Set("Content-Type", "application/x-yaml")
w.Header().Set("X-OCDP-Kubeconfig-Expires-At", kubeconfig.ExpiresAt.Format(time.RFC3339))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(kubeconfig.Kubeconfig))
}
func (h *WorkspaceHandler) SuspendWorkspace(w http.ResponseWriter, r *http.Request) {
workspaceID := mux.Vars(r)["workspace_id"]
if err := h.workspaceService.SuspendWorkspace(r.Context(), workspaceID); err != nil {
respondServiceError(w, err, "Failed to suspend workspace")
return
}
w.WriteHeader(http.StatusNoContent)
}
func toWorkspaceResponse(workspace *entity.Workspace) workspaceResponse {
return workspaceResponse{
ID: workspace.ID,
Name: workspace.Name,
Status: string(workspace.Status),
K8sNamespace: workspace.K8sNamespace,
K8sSAName: workspace.K8sSAName,
DefaultClusterID: workspace.DefaultClusterID,
QuotaCPU: workspace.QuotaCPU,
QuotaMemory: workspace.QuotaMemory,
QuotaGPU: workspace.QuotaGPU,
QuotaGPUMem: workspace.QuotaGPUMem,
CreatedBy: workspace.CreatedBy,
CreatedAt: workspace.CreatedAt.Format(time.RFC3339),
UpdatedAt: workspace.UpdatedAt.Format(time.RFC3339),
}
}
func respondServiceError(w http.ResponseWriter, err error, fallback string) {
switch err {
case entity.ErrUnauthorized, authz.ErrUnauthenticated:
respondError(w, http.StatusUnauthorized, "Unauthorized", err.Error())
case entity.ErrForbidden, authz.ErrForbidden, entity.ErrUserInactive, entity.ErrWorkspaceSuspended:
respondError(w, http.StatusForbidden, "Forbidden", err.Error())
case entity.ErrClusterNotFound, entity.ErrRegistryNotFound, entity.ErrInstanceNotFound, entity.ErrWorkspaceNotFound:
respondError(w, http.StatusNotFound, fallback, err.Error())
default:
respondError(w, http.StatusBadRequest, fallback, err.Error())
}
}