diff --git a/.gitignore b/.gitignore index fea6c0e..ad6a03d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ build/ backend/bin/ frontend/dist/ +# Compiled binaries +backend/ocdp-backend + # Logs *.log logs/ @@ -61,3 +64,17 @@ tmp/ temp/ *.tmp +# Next.js stale build caches +frontend/.next.stale*/ + +# Debug/temp scripts +debug_*.py +test_*.py + +# Kubeconfig (contains sensitive credentials) +*.kubeconfig +kubeconfig + +# AI model output / context storage +.claude/ + diff --git a/Multi-Tenant Kubeconfig.md b/Multi-Tenant Kubeconfig.md new file mode 100644 index 0000000..4363f4d --- /dev/null +++ b/Multi-Tenant Kubeconfig.md @@ -0,0 +1,127 @@ +# Technical Specification: Multi-Tenant Kubeconfig & Auth Gateway + +## 1. System Overview & Goals +- **Objective**: Develop a backend API service that automates Kubernetes multi-tenant onboarding (Namespace + Quota isolation) and securely distributes short-lived, dynamic `kubeconfig` files using the Kubernetes `TokenRequest` API. +- **Architecture Independence**: This backend service acts as a standalone control plane. It is **not** strictly bound to a BFF pattern and does **not** need to run inside the target Kubernetes cluster (it supports Out-of-Cluster execution). +- **Out of Scope**: This spec does NOT cover the frontend UI implementation or the downstream workload deployment. It focuses strictly on identity, tenant provisioning, and credential brokering. +- **Security Principles**: Adhere strictly to Zero-Knowledge architecture (no token storage in DB), Ephemeral Credentials (short-lived tokens only), and Least Privilege (the Gateway must NOT be a `cluster-admin`). + +## 2. Architecture & Topology +- **Tech Stack**: Go `net/http` (or FastAPI), utilizing the official Kubernetes Client SDK (`client-go` or `kubernetes-client/python`). +- **Control Plane Flow**: + 1. Client/Frontend -> Gateway: User requests environment access. + 2. Gateway -> K8s API: Gateway authenticates to the target K8s cluster using its own master credentials (e.g., an Out-of-Cluster `kubeconfig`). + 3. Gateway -> K8s API: Executes Namespace/SA creation (if new) or calls `TokenRequest` API (if existing). + 4. Gateway -> Client/Frontend: Returns a generated `kubeconfig` YAML string with the short-lived JWT token. + +## 3. Core Business Logic Workflows + +### Phase 1: Tenant Initialization (Onboarding) +Triggered when a new user registers or requests a workspace for the first time. The Gateway must execute a K8s transaction creating four resources: +1. **Namespace**: `tenant-{user_uuid}` +2. **ServiceAccount**: `sa-tenant-admin` (Created inside the tenant's namespace). +3. **RoleBinding**: Bind `sa-tenant-admin` to the `admin` (or custom) ClusterRole, strictly isolated within `tenant-{user_uuid}`. +4. **ResourceQuota**: Enforce limits (e.g., `requests.cpu: "4"`, `limits.memory: "16Gi"`) to prevent noisy neighbors. + +### Phase 2: Credential Distribution (Dynamic Token) +Triggered when the user requests CLI access or downloads a kubeconfig. +1. Locate the user's associated Namespace and ServiceAccount, verifying the user's ownership of the workspace. +2. Audit Logging: Record the credential issuance event (User, IP, Workspace) into the database. +3. Call the `authentication.k8s.io/v1 TokenRequest` API targeting `sa-tenant-admin` in the specific tenant's namespace. +4. Set `expirationSeconds: 7200` (2 hours). Hard limit; cannot be extended. +5. Retrieve the generated JWT token and inject it into a pre-defined `kubeconfig` text template. + +### Phase 3: Automated Renewal & Emergency Suspension +- **Session Management**: If accessed via a Web UI, the Gateway intercepts requests, attaches the dynamic token, and forwards them. If the token is within 10 minutes of expiration, the Gateway automatically issues a new TokenRequest. +- **Emergency Suspension**: If a workspace is marked compromised, the Gateway deletes its K8s `RoleBinding`, instantly revoking access for all currently active tokens of that tenant. + +## 4. API Contracts + +### 4.1. Initialize Tenant Workspace +- **Route**: `POST /api/v1/workspaces/init` +- **Auth**: Gateway Session / Bearer Token +- **Rate Limit**: Strictly rate-limited per user to prevent Namespace exhaustion. +- **Request Payload**: + ```json + { + "tier": "basic" // Determines the ResourceQuota template + } +- **Response Payload (201 Created)**: + ```json + { + "namespace": "tenant-a1b2c3d4", + "status": "provisioned", + "quota": {"cpu": "4", "memory": "8Gi"} + } + ``` +### 4.2. Generate Dynamic Kubeconfig +- **Route**: `GET /api/v1/workspaces/credentials/kubeconfig` +- **Auth**: Gateway Session / Bearer Token +- **Request Payload(200 OK)**: Returns raw `application/x-yaml`content. + ```yaml + apiVersion: v1 + clusters: + - cluster: + server: https:// + certificate-authority-data: + name: internal-cluster + contexts: + - context: + cluster: internal-cluster + namespace: tenant-a1b2c3d4 # Default context locked to their namespace + user: sa-tenant-admin + name: tenant-context + current-context: tenant-context + kind: Config + users: + - name: sa-tenant-admin + user: + token: "eyJhbGciOiJSUzI1NiIs..." # Short-lived token injected here + ``` + +### 4.3. Suspend Workspace (Emergency Kill Switch) +- **Route**: POST /api/v1/workspaces/{id}/suspend +- **Auth**: Admin Only +- **Behavior**: Updates DB status to suspended and deletes the associated K8s RoleBinding. + + +### 5. Data Architecture & Persistence +- **Database**: PostgreSQL (Relational mapping between Users and K8s Namespaces). +- **Table**: `users` + - `id` (UUID, PK),`email`,`password_hash`,`status` +- **Table**: `workspaces` + + - `id` (UUID, PK) + + - `user_id` (UUID, FK to Users table) + + - `k8s_namespace` (String, unique) + + - `k8s_sa_name` (String) + + - `tier` (String) + + - `created_at` (Timestamp) +- **Table**: `audit_logs`(Security Compliance) + - `id` (UUID, PK), `user_id` (UUID), `workspace_id` (UUID), `action` (e.g., IssueKubeconfig), `ip_address`, `created_at` +- **Constraint**: We do NOT store the K8s Token in the database. Tokens are ephemeral and generated on-the-fly. + +## 6. Security, Threat Mitigation & Infrastructure Constraints + +### 6.1 Threat Model +| Threat | Mitigation Strategy | +| :--- | :--- | +| **Gateway Compromise** | The Gateway uses a strictly restricted K8s role. It cannot read existing `Secrets` or interfere with other tenants' running Pods. | +| **Token Theft (XSS)** | Application-level Auth must use `HttpOnly, Secure` Cookies. Generated Kubeconfigs expire in 2 hours. | +| **Resource Abuse (Mining)** | Hardcoded `ResourceQuota` per tenant upon creation. Global `LimitRange` enforced at the cluster level. | + +### 6.2 Restricted Gateway Credentials (Crucial) +The Gateway requires a K8s credential (Out-of-Cluster `kubeconfig` or Cloud IAM Role) to operate. **This credential MAY NOT have `cluster-admin` privileges.** It should be bound to a custom `ClusterRole` with ONLY the following permissions: +- `create`, `get`, `list` on `namespaces`, `resourcequotas`. +- `create`, `get`, `list` on `serviceaccounts`, `rolebindings`. +- `create` on `serviceaccounts/token` (CRITICAL for TokenRequest API). +- *Strictly prohibited*: `get` or `list` on `secrets`, `pods`, or `deployments`. + +### 6.3 Deployment & Networking +- **Deployment Agnostic**: The application will be packaged as a Docker image and can be deployed via Docker Compose, standalone VMs, or within a Kubernetes cluster. +- **CORS/CSP**: Since this might not be a single-origin BFF, explicit CORS policies (`Access-Control-Allow-Origin`) must be tightly defined if the frontend is hosted on a separate domain. Wildcards (`*`) are prohibited. \ No newline at end of file diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 0e31fce..0b92ec7 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -156,6 +156,9 @@ func main() { storageService := service.NewStorageService(repos.StorageRepo) storageHandler := rest.NewStorageHandler(storageService) + // Wire storage service into instance service for layered storage config + instanceService.SetStorageService(storageService) + // Chart Reference Handler chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo) chartRefHandler := rest.NewChartReferenceHandler(chartRefService) @@ -377,6 +380,7 @@ func setupRouter( // ===== Storage Backend 路由 ===== api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost) api.HandleFunc("/storage-backends", storageHandler.GetAllStorage).Methods(http.MethodGet) + api.HandleFunc("/storage-backends/resolve", storageHandler.ResolveStorage).Methods(http.MethodGet) api.HandleFunc("/storage-backends/{storage_id}", storageHandler.GetStorage).Methods(http.MethodGet) api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut) api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 564e5b6..f8c77ce 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -61,19 +61,37 @@ services: image: ocdp-backend:latest container_name: ocdp-backend restart: unless-stopped + env_file: + - /media/ivanwu/DATA/ocdp-go/.env environment: ADAPTER_MODE: ${ADAPTER_MODE:-production} PORT: 8080 JWT_SECRET: ${JWT_SECRET:-change-me-in-production} ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here} DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable - KUBECONFIG: ${KUBECONFIG:-.kube/config} - HARBOR_URL: ${HARBOR_URL:-} - HARBOR_USERNAME: ${HARBOR_USERNAME:-} - HARBOR_PASSWORD: ${HARBOR_PASSWORD:-} - NFS_SERVER: ${NFS_SERVER:-} - NFS_SHARE: ${NFS_SHARE:-} + KUBECONFIG: "" ALLOWED_DEV_ORIGINS: ${ALLOWED_DEV_ORIGINS:-*} + # Bootstrap data (loaded from .env via env_file above) + BOOTSTRAP_ADMIN_USER: ${BOOTSTRAP_ADMIN_USER:-} + BOOTSTRAP_ADMIN_PASS: ${BOOTSTRAP_ADMIN_PASS:-} + BOOTSTRAP_ADMIN_EMAIL: ${BOOTSTRAP_ADMIN_EMAIL:-} + BOOTSTRAP_REGISTRY_NAME: ${BOOTSTRAP_REGISTRY_NAME:-} + BOOTSTRAP_REGISTRY_URL: ${BOOTSTRAP_REGISTRY_URL:-} + BOOTSTRAP_REGISTRY_DESC: ${BOOTSTRAP_REGISTRY_DESC:-} + BOOTSTRAP_REGISTRY_USER: ${BOOTSTRAP_REGISTRY_USER:-} + BOOTSTRAP_REGISTRY_PASS: ${BOOTSTRAP_REGISTRY_PASS:-} + BOOTSTRAP_REGISTRY_INSECURE: ${BOOTSTRAP_REGISTRY_INSECURE:-} + BOOTSTRAP_CLUSTERS: ${BOOTSTRAP_CLUSTERS:-} + BOOTSTRAP_CLUSTER_CLUSTER1_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER1_HOST:-} + BOOTSTRAP_CLUSTER_CLUSTER1_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER1_DESC:-} + BOOTSTRAP_CLUSTER_CLUSTER1_CA: ${BOOTSTRAP_CLUSTER_CLUSTER1_CA:-} + BOOTSTRAP_CLUSTER_CLUSTER1_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER1_CERT:-} + BOOTSTRAP_CLUSTER_CLUSTER1_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER1_KEY:-} + BOOTSTRAP_CLUSTER_CLUSTER2_HOST: ${BOOTSTRAP_CLUSTER_CLUSTER2_HOST:-} + BOOTSTRAP_CLUSTER_CLUSTER2_DESC: ${BOOTSTRAP_CLUSTER_CLUSTER2_DESC:-} + BOOTSTRAP_CLUSTER_CLUSTER2_CA: ${BOOTSTRAP_CLUSTER_CLUSTER2_CA:-} + BOOTSTRAP_CLUSTER_CLUSTER2_CERT: ${BOOTSTRAP_CLUSTER_CLUSTER2_CERT:-} + BOOTSTRAP_CLUSTER_CLUSTER2_KEY: ${BOOTSTRAP_CLUSTER_CLUSTER2_KEY:-} ports: - "${BACKEND_PORT:-8080}:8080" volumes: diff --git a/backend/internal/adapter/input/http/dto/converter.go b/backend/internal/adapter/input/http/dto/converter.go index 7b6766c..e11cc69 100644 --- a/backend/internal/adapter/input/http/dto/converter.go +++ b/backend/internal/adapter/input/http/dto/converter.go @@ -76,6 +76,7 @@ func WorkspaceDTOFromEntity(workspace *entity.Workspace) *WorkspaceDTO { return &WorkspaceDTO{ ID: workspace.ID, Name: workspace.Name, + ClusterIDs: workspace.ClusterIDs, Description: workspace.Description, CreatedBy: workspace.CreatedBy, CreatedAt: workspace.CreatedAt, diff --git a/backend/internal/adapter/input/http/dto/storage_dto.go b/backend/internal/adapter/input/http/dto/storage_dto.go index 0dee6b5..10307a9 100644 --- a/backend/internal/adapter/input/http/dto/storage_dto.go +++ b/backend/internal/adapter/input/http/dto/storage_dto.go @@ -7,6 +7,7 @@ type CreateStorageRequest struct { Description string `json:"description"` IsDefault bool `json:"is_default"` IsShared bool `json:"is_shared"` + ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage // NFS 配置 NFS NFSConfigDTO `json:"nfs,omitempty"` @@ -23,6 +24,7 @@ type UpdateStorageRequest struct { Description string `json:"description"` IsDefault bool `json:"is_default"` IsShared bool `json:"is_shared"` + ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage // NFS 配置 NFS NFSConfigDTO `json:"nfs,omitempty"` @@ -52,17 +54,18 @@ type HostPathConfigDTO struct { // StorageResponse 存储后端响应 type StorageResponse struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id,omitempty"` - OwnerID string `json:"owner_id,omitempty"` - Name string `json:"name"` - Type string `json:"type"` - Config StorageConfigDTO `json:"config"` - Description string `json:"description"` - IsDefault bool `json:"is_default"` - IsShared bool `json:"is_shared"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id,omitempty"` + ClusterID string `json:"cluster_id,omitempty"` + OwnerID string `json:"owner_id,omitempty"` + Name string `json:"name"` + Type string `json:"type"` + Config StorageConfigDTO `json:"config"` + Description string `json:"description"` + IsDefault bool `json:"is_default"` + IsShared bool `json:"is_shared"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // StorageConfigDTO 存储配置(脱敏后) diff --git a/backend/internal/adapter/input/http/dto/workspace_dto.go b/backend/internal/adapter/input/http/dto/workspace_dto.go index 4986934..bb35dcd 100644 --- a/backend/internal/adapter/input/http/dto/workspace_dto.go +++ b/backend/internal/adapter/input/http/dto/workspace_dto.go @@ -4,24 +4,32 @@ import "time" // WorkspaceDTO 工作空间 DTO type WorkspaceDTO struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + ClusterIDs []string `json:"cluster_ids,omitempty"` + Quotas []*QuotaDTO `json:"quotas,omitempty"` + Description string `json:"description,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } -// CreateWorkspaceRequest 创建工作空间请求 +// CreateWorkspaceRequest 创建工作空间请求(包含配额设置) type CreateWorkspaceRequest struct { Name string `json:"name" validate:"required"` Description string `json:"description"` + ClusterIDs []string `json:"cluster_ids"` + // Quotas can be set during creation + CPU *QuotaValue `json:"cpu"` + GPU *QuotaValue `json:"gpu"` + GPUMemory *QuotaValue `json:"gpu_memory"` } // UpdateWorkspaceRequest 更新工作空间请求 type UpdateWorkspaceRequest struct { - Name string `json:"name"` - Description string `json:"description"` + Name string `json:"name"` + Description string `json:"description"` + ClusterIDs []string `json:"cluster_ids"` } // QuotaDTO 配额 DTO diff --git a/backend/internal/adapter/input/http/rest/auth_handler.go b/backend/internal/adapter/input/http/rest/auth_handler.go index f67acda..60da1d0 100644 --- a/backend/internal/adapter/input/http/rest/auth_handler.go +++ b/backend/internal/adapter/input/http/rest/auth_handler.go @@ -83,14 +83,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { // 获取用户信息 // TODO: 从 token 解析用户信息或从服务获取 - // 返回响应 + // 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致 response := &dto.AuthResponse{ AccessToken: accessToken, RefreshToken: refreshToken, Username: req.Username, } - respondJSON(w, http.StatusOK, response) + respondSuccess(w, "Login successful", response) } // RefreshToken 刷新 Token @@ -117,11 +117,11 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) { return } - // 返回响应 + // 返回响应 - 使用 respondSuccess 包装 response := &dto.AuthResponse{ AccessToken: newAccessToken, RefreshToken: req.RefreshToken, } - respondJSON(w, http.StatusOK, response) + respondSuccess(w, "Token refreshed", response) } diff --git a/backend/internal/adapter/input/http/rest/instance_handler.go b/backend/internal/adapter/input/http/rest/instance_handler.go index f6b6b77..bbc686e 100644 --- a/backend/internal/adapter/input/http/rest/instance_handler.go +++ b/backend/internal/adapter/input/http/rest/instance_handler.go @@ -188,6 +188,7 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request) LastOperation: string(instance.LastOperation), LastError: instance.LastError, Revision: instance.Revision, + Values: instance.Values, CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), }) diff --git a/backend/internal/adapter/input/http/rest/storage_handler.go b/backend/internal/adapter/input/http/rest/storage_handler.go index e1e749c..9610142 100644 --- a/backend/internal/adapter/input/http/rest/storage_handler.go +++ b/backend/internal/adapter/input/http/rest/storage_handler.go @@ -10,6 +10,14 @@ import ( "github.com/ocdp/cluster-service/internal/domain/service" ) +// StorageResolutionResponse 分层存储解析响应 +type StorageResolutionResponse struct { + Storage *dto.StorageResponse `json:"storage,omitempty"` + ValuesYAML string `json:"values_yaml,omitempty"` + Source string `json:"source,omitempty"` // workspace, cluster, shared + Message string `json:"message,omitempty"` +} + // StorageHandler Storage Backend Handler type StorageHandler struct { storageService *service.StorageService @@ -77,6 +85,7 @@ func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) { req.Description, req.IsDefault, req.IsShared, + req.ClusterID, ) if err != nil { respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error()) @@ -252,6 +261,45 @@ func (h *StorageHandler) DeleteStorage(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// ResolveStorage 预览分层存储解析结果 +// @Summary 预览分层存储解析结果 +// @Description 根据 cluster_id 和 workspace_id 解析出最终生效的存储配置 +// @Tags Storage +// @Produce json +// @Security BearerAuth +// @Param cluster_id query string false "Cluster ID" +// @Param workspace_id query string false "Workspace ID" +// @Success 200 {object} StorageResolutionResponse +// @Router /storage-backends/resolve [get] +func (h *StorageHandler) ResolveStorage(w http.ResponseWriter, r *http.Request) { + clusterID := r.URL.Query().Get("cluster_id") + workspaceID := r.URL.Query().Get("workspace_id") + if workspaceID == "" { + workspaceID = r.Header.Get("X-Workspace-ID") + } + + resolution, err := h.storageService.ResolveStorageConfig(r.Context(), clusterID, workspaceID) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to resolve storage config", err.Error()) + return + } + + if resolution == nil || resolution.Storage == nil { + respondJSON(w, http.StatusOK, &StorageResolutionResponse{ + Message: "No default storage configured", + }) + return + } + + response := &StorageResolutionResponse{ + Storage: toStorageResponse(resolution.Storage), + ValuesYAML: resolution.ValuesYAML, + Source: resolution.Source, + } + + respondJSON(w, http.StatusOK, response) +} + // toStorageResponse 转换为响应 DTO func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse { config := dto.StorageConfigDTO{} @@ -278,6 +326,7 @@ func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse { return &dto.StorageResponse{ ID: storage.ID, WorkspaceID: storage.WorkspaceID, + ClusterID: storage.ClusterID, OwnerID: storage.OwnerID, Name: storage.Name, Type: string(storage.Type), diff --git a/backend/internal/adapter/input/http/rest/workspace_handler.go b/backend/internal/adapter/input/http/rest/workspace_handler.go index 66e0cc4..3af00b2 100644 --- a/backend/internal/adapter/input/http/rest/workspace_handler.go +++ b/backend/internal/adapter/input/http/rest/workspace_handler.go @@ -26,7 +26,7 @@ func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService // CreateWorkspace 创建工作空间 // @Summary 创建工作空间 -// @Description 创建新的工作空间(Admin 专用) +// @Description 创建新的工作空间(Admin 专用,支持 cluster_ids 和初始配额) // @Tags workspace // @Accept json // @Produce json @@ -48,7 +48,31 @@ func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Reques // 获取创建者 ID userID := GetUserIDFromRequest(r) - workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID) + // 准备配额 + quotas := make(map[entity.ResourceType]struct { + HardLimit float64 + SoftLimit float64 + }) + if req.CPU != nil { + quotas[entity.ResourceCPU] = struct { + HardLimit float64 + SoftLimit float64 + }{req.CPU.HardLimit, req.CPU.SoftLimit} + } + if req.GPU != nil { + quotas[entity.ResourceGPU] = struct { + HardLimit float64 + SoftLimit float64 + }{req.GPU.HardLimit, req.GPU.SoftLimit} + } + if req.GPUMemory != nil { + quotas[entity.ResourceGPUMemory] = struct { + HardLimit float64 + SoftLimit float64 + }{req.GPUMemory.HardLimit, req.GPUMemory.SoftLimit} + } + + workspace, err := h.workspaceService.Create(r.Context(), req.Name, req.Description, userID, req.ClusterIDs, quotas) if err != nil { respondError(w, http.StatusBadRequest, err.Error(), "") return @@ -129,6 +153,9 @@ func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Reques if req.Description != "" { workspace.Description = req.Description } + if req.ClusterIDs != nil { + workspace.ClusterIDs = req.ClusterIDs + } if err := h.workspaceService.Update(r.Context(), workspace); err != nil { respondError(w, http.StatusBadRequest, err.Error(), "") diff --git a/backend/internal/adapter/output/helm/real/helm_client.go b/backend/internal/adapter/output/helm/real/helm_client.go index 4d95fda..b33208f 100644 --- a/backend/internal/adapter/output/helm/real/helm_client.go +++ b/backend/internal/adapter/output/helm/real/helm_client.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "os" "path/filepath" "time" @@ -113,22 +114,24 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta install.Namespace = instance.Namespace install.CreateNamespace = true install.Wait = true - install.Timeout = 5 * time.Minute + install.Timeout = 1 * time.Minute // 加载 Chart(从本地路径或 OCI registry) - // 这里简化处理,假设 chart 已经被拉取到本地 chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version) - chart, err := loader.Load(chartPath) if err != nil { return fmt.Errorf("failed to load chart: %w", err) } // 执行安装 + log.Printf("[helm-install] step=run instance=%s values=%v", instance.Name, instance.Values) + t0 := time.Now() rel, err := install.Run(chart, instance.Values) + log.Printf("[helm-install] step=runDone instance=%s elapsed=%v err=%v", instance.Name, time.Since(t0), err) if err != nil { return fmt.Errorf("failed to install release: %w", err) } + log.Printf("[helm-install] step=done instance=%s revision=%d", instance.Name, rel.Version) // 更新 revision(状态由调用方根据操作结果设置) instance.Revision = rel.Version diff --git a/backend/internal/adapter/output/oci/real/oci_client.go b/backend/internal/adapter/output/oci/real/oci_client.go index 924f173..46bf328 100644 --- a/backend/internal/adapter/output/oci/real/oci_client.go +++ b/backend/internal/adapter/output/oci/real/oci_client.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "os" "path/filepath" @@ -74,23 +75,147 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error) } // ListRepositories 列出 Registry 中的所有 repositories +// 优先使用 OCI _catalog API,失败时回退到 Harbor REST API v2 func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) { - reg, err := c.getRegistry(registry) - if err != nil { - return nil, err - } - repositories := make([]string, 0) - err = reg.Repositories(ctx, "", func(repos []string) error { - repositories = append(repositories, repos...) - return nil - }) + // 尝试 OCI _catalog API + reg, err := c.getRegistry(registry) + log.Printf("[DEBUG ListRepositories] registry=%s, getRegistry err=%v", registry.URL, err) + if err == nil { + err = reg.Repositories(ctx, "", func(repos []string) error { + log.Printf("[DEBUG ListRepositories] OCI got repos batch: %d", len(repos)) + repositories = append(repositories, repos...) + return nil + }) + log.Printf("[DEBUG ListRepositories] OCI reg.Repositories returned: err=%v, total_repos=%d", err, len(repositories)) + } + + log.Printf("[DEBUG ListRepositories] post-OCI check: err=%v, repos_count=%d", err, len(repositories)) + + if err == nil && len(repositories) > 0 { + log.Printf("[DEBUG ListRepositories] OCI success, returning %d repos", len(repositories)) + return repositories, nil + } + + // 回退: 使用 Harbor REST API v2 + log.Printf("[Harbor Fallback] OCI failed (err=%v, repos=%d), checking if Harbor...", err, len(repositories)) + log.Printf("[Harbor Fallback] registry.URL=%s, contains 'harbor'=%v", registry.URL, strings.Contains(registry.URL, "harbor")) + + if strings.Contains(registry.URL, "harbor") { + log.Printf("[Harbor Fallback] Yes, this is Harbor! Calling Harbor REST API...") + repos, fallbackErr := c.listHarborRepositories(registry) + log.Printf("[Harbor Fallback] Got %d repos, err=%v", len(repos), fallbackErr) + if fallbackErr == nil && len(repos) > 0 { + log.Printf("[Harbor Fallback] Returning %d repos from Harbor API", len(repos)) + return repos, nil + } + if err != nil { + return nil, fmt.Errorf("failed to list repositories: %w", err) + } + return nil, fallbackErr + } if err != nil { return nil, fmt.Errorf("failed to list repositories: %w", err) } + return repositories, nil +} +// listHarborRepositories 使用 Harbor REST API v2 获取仓库列表 +func (c *OCIClient) listHarborRepositories(registry *entity.Registry) ([]string, error) { + // 解析 Harbor URL 基础地址 + baseURL := registry.URL + baseURL = strings.TrimSuffix(baseURL, "/") + baseURL = strings.TrimPrefix(baseURL, "https://") + baseURL = strings.TrimPrefix(baseURL, "http://") + harborHost := "https://" + baseURL + + // 获取认证信息 + username := registry.Username + password := registry.Password + if username == "" || password == "" { + username = os.Getenv("HARBOR_USERNAME") + password = os.Getenv("HARBOR_PASSWORD") + } + + // 获取项目列表 + projectsURL := harborHost + "/api/v2.0/projects" + req, err := http.NewRequest("GET", projectsURL, nil) + if err != nil { + return nil, err + } + req.SetBasicAuth(username, password) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to list projects: status %d", resp.StatusCode) + } + + var projects []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&projects); err != nil { + return nil, err + } + + repositories := make([]string, 0) + pageSize := 100 + + for _, project := range projects { + page := 1 + log.Printf("[listHarborRepositories] Processing project: %s", project.Name) + for { + reposURL := fmt.Sprintf("%s/api/v2.0/projects/%s/repositories?page=%d&page_size=%d", + harborHost, project.Name, page, pageSize) + req, err := http.NewRequest("GET", reposURL, nil) + if err != nil { + log.Printf("[listHarborRepositories] page %d: NewRequest error: %v", page, err) + break + } + req.SetBasicAuth(username, password) + + resp, err := c.httpClient.Do(req) + if err != nil { + log.Printf("[listHarborRepositories] page %d: Do error: %v", page, err) + break + } + + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + resp.Body.Close() + log.Printf("[listHarborRepositories] page %d: HTTP %d, body: %s", page, resp.StatusCode, string(bodyBytes)) + break + } + + var repos []struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil { + resp.Body.Close() + log.Printf("[listHarborRepositories] page %d: Decode error: %v", page, err) + break + } + resp.Body.Close() + + log.Printf("[listHarborRepositories] page %d: got %d repos", page, len(repos)) + if len(repos) == 0 { + break + } + + for _, repo := range repos { + repositories = append(repositories, repo.Name) + } + page++ + } + } + + log.Printf("[listHarborRepositories] Total repos collected: %d", len(repositories)) return repositories, nil } diff --git a/backend/internal/adapter/output/persistence/mock/storage_repository_mock.go b/backend/internal/adapter/output/persistence/mock/storage_repository_mock.go index 437f991..3b16b83 100644 --- a/backend/internal/adapter/output/persistence/mock/storage_repository_mock.go +++ b/backend/internal/adapter/output/persistence/mock/storage_repository_mock.go @@ -83,7 +83,28 @@ func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID stri return s, nil } } - return nil, entity.ErrStorageNotFound + return nil, nil +} + +// GetByCluster 获取 cluster 关联的存储后端 +func (r *StorageRepositoryMock) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) { + var result []*entity.StorageBackend + for _, s := range r.storages { + if s.ClusterID == clusterID { + result = append(result, s) + } + } + return result, nil +} + +// GetDefaultByCluster 获取 cluster 的默认存储后端 +func (r *StorageRepositoryMock) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) { + for _, s := range r.storages { + if s.ClusterID == clusterID && s.IsDefault { + return s, nil + } + } + return nil, nil } // List 列出所有存储(管理员用) diff --git a/backend/internal/adapter/output/persistence/postgres/cluster_repository.go b/backend/internal/adapter/output/persistence/postgres/cluster_repository.go index 1baf2f6..5801966 100644 --- a/backend/internal/adapter/output/persistence/postgres/cluster_repository.go +++ b/backend/internal/adapter/output/persistence/postgres/cluster_repository.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log" "time" "github.com/google/uuid" @@ -124,11 +125,11 @@ func (r *ClusterRepository) GetByID(ctx context.Context, id string) (*entity.Clu return nil, fmt.Errorf("failed to get cluster: %w", err) } - // 解密敏感数据 - cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData) - cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData) - cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData) - cluster.Token, _ = r.encryptor.Decrypt(encryptedToken) + // 解密敏感数据(检测 kubeconfig 格式则跳过解密) + cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data") + cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data") + cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data") + cluster.Token = r.decryptIfNeeded(encryptedToken, "token") return cluster, nil } @@ -169,15 +170,35 @@ func (r *ClusterRepository) GetByName(ctx context.Context, name string) (*entity return nil, fmt.Errorf("failed to get cluster: %w", err) } - // 解密敏感数据 - cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData) - cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData) - cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData) - cluster.Token, _ = r.encryptor.Decrypt(encryptedToken) + // 解密敏感数据(检测 kubeconfig 格式则跳过解密) + cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data") + cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data") + cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data") + cluster.Token = r.decryptIfNeeded(encryptedToken, "token") return cluster, nil } +// decryptIfNeeded 解密数据。如果数据以 "apiVersion:" 或 "kind:" 开头(kubeconfig 格式), +// 则跳过解密直接返回原值。 +func (r *ClusterRepository) decryptIfNeeded(data string, fieldName string) string { + if data == "" { + return "" + } + // 检测 kubeconfig 格式(明文 YAML) + if (len(data) > 10 && data[:11] == "apiVersion:") || + (len(data) > 5 && data[:5] == "kind:") { + return data + } + // 否则尝试解密 + decrypted, err := r.encryptor.Decrypt(data) + if err != nil { + log.Printf("[ClusterRepository] WARNING: failed to decrypt %s for field %s: %v (field will be empty)", data[:min(50, len(data))], fieldName, err) + return "" + } + return decrypted +} + // Update 更新集群 func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error { cluster.UpdatedAt = time.Now() @@ -352,18 +373,18 @@ func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, err cluster.OwnerID = ownerID.String cluster.DefaultNamespace = defaultNamespace.String - // 解密敏感数据 + // 解密敏感数据(检测 kubeconfig 格式则跳过解密) if encryptedCAData.Valid { - cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData.String) + cluster.CAData = r.decryptIfNeeded(encryptedCAData.String, "ca_data") } if encryptedCertData.Valid { - cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData.String) + cluster.CertData = r.decryptIfNeeded(encryptedCertData.String, "cert_data") } if encryptedKeyData.Valid { - cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData.String) + cluster.KeyData = r.decryptIfNeeded(encryptedKeyData.String, "key_data") } if encryptedToken.Valid { - cluster.Token, _ = r.encryptor.Decrypt(encryptedToken.String) + cluster.Token = r.decryptIfNeeded(encryptedToken.String, "token") } clusters = append(clusters, cluster) diff --git a/backend/internal/adapter/output/persistence/postgres/storage_repository.go b/backend/internal/adapter/output/persistence/postgres/storage_repository.go index 2481e44..edcfcb2 100644 --- a/backend/internal/adapter/output/persistence/postgres/storage_repository.go +++ b/backend/internal/adapter/output/persistence/postgres/storage_repository.go @@ -12,6 +12,14 @@ import ( "github.com/ocdp/cluster-service/internal/domain/repository" ) +// sqlNullString converts empty string to sql.NullString for proper NULL handling +func sqlNullString(s string) interface{} { + if s == "" { + return sql.NullString{Valid: false} + } + return sql.NullString{String: s, Valid: true} +} + // StorageRepository PostgreSQL 存储后端仓储实现 type StorageRepository struct { db *DB @@ -34,14 +42,15 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB } query := ` - INSERT INTO storage_backends (id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO storage_backends (id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ` _, err = r.db.conn.ExecContext(ctx, query, storage.ID, storage.WorkspaceID, - storage.OwnerID, + sqlNullString(storage.ClusterID), + sqlNullString(storage.OwnerID), storage.Name, storage.Type, configJSON, @@ -62,17 +71,19 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB // GetByID 根据 ID 获取存储后端 func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends WHERE id = $1 ` storage := &entity.StorageBackend{} var configJSON []byte + var wsID, clusterID, ownerID sql.NullString err := r.db.conn.QueryRowContext(ctx, query, id).Scan( &storage.ID, - &storage.WorkspaceID, - &storage.OwnerID, + &wsID, + &clusterID, + &ownerID, &storage.Name, &storage.Type, &configJSON, @@ -82,6 +93,9 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto &storage.CreatedAt, &storage.UpdatedAt, ) + storage.WorkspaceID = wsID.String + storage.ClusterID = clusterID.String + storage.OwnerID = ownerID.String if err == sql.ErrNoRows { return nil, entity.ErrStorageNotFound @@ -100,17 +114,19 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto // GetByName 根据名称获取存储后端 func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends WHERE workspace_id = $1 AND name = $2 ` storage := &entity.StorageBackend{} var configJSON []byte + var wsID, clusterID, ownerID sql.NullString err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan( &storage.ID, - &storage.WorkspaceID, - &storage.OwnerID, + &wsID, + &clusterID, + &ownerID, &storage.Name, &storage.Type, &configJSON, @@ -120,6 +136,9 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str &storage.CreatedAt, &storage.UpdatedAt, ) + storage.WorkspaceID = wsID.String + storage.ClusterID = clusterID.String + storage.OwnerID = ownerID.String if err == sql.ErrNoRows { return nil, entity.ErrStorageNotFound @@ -138,7 +157,7 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str // GetByWorkspace 获取 workspace 的所有存储后端 func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends WHERE workspace_id = $1 OR is_shared = TRUE ORDER BY is_default DESC, name @@ -156,7 +175,7 @@ func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID stri // GetShared 获取所有共享存储后端 func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends WHERE is_shared = TRUE ORDER BY name @@ -174,7 +193,7 @@ func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBac // GetDefault 获取 workspace 的默认存储后端 func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends WHERE workspace_id = $1 AND is_default = TRUE LIMIT 1 @@ -182,10 +201,12 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) storage := &entity.StorageBackend{} var configJSON []byte + var wsID, clusterID, ownerID sql.NullString err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan( &storage.ID, - &storage.WorkspaceID, - &storage.OwnerID, + &wsID, + &clusterID, + &ownerID, &storage.Name, &storage.Type, &configJSON, @@ -195,6 +216,9 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) &storage.CreatedAt, &storage.UpdatedAt, ) + storage.WorkspaceID = wsID.String + storage.ClusterID = clusterID.String + storage.OwnerID = ownerID.String if err == sql.ErrNoRows { return nil, nil @@ -210,6 +234,68 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) return storage, nil } +// GetByCluster 获取 cluster 关联的存储后端列表 +func (r *StorageRepository) GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) { + query := ` + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + FROM storage_backends + WHERE cluster_id = $1 + ORDER BY is_default DESC, name + ` + + rows, err := r.db.conn.QueryContext(ctx, query, clusterID) + if err != nil { + return nil, fmt.Errorf("failed to list storage by cluster: %w", err) + } + defer rows.Close() + + return r.scanStorages(rows) +} + +// GetDefaultByCluster 获取 cluster 的默认存储后端 +func (r *StorageRepository) GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) { + query := ` + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + FROM storage_backends + WHERE cluster_id = $1 AND is_default = TRUE + LIMIT 1 + ` + + storage := &entity.StorageBackend{} + var configJSON []byte + var wsID, clusterIDNull, ownerID sql.NullString + err := r.db.conn.QueryRowContext(ctx, query, clusterID).Scan( + &storage.ID, + &wsID, + &clusterIDNull, + &ownerID, + &storage.Name, + &storage.Type, + &configJSON, + &storage.Description, + &storage.IsDefault, + &storage.IsShared, + &storage.CreatedAt, + &storage.UpdatedAt, + ) + storage.WorkspaceID = wsID.String + storage.ClusterID = clusterIDNull.String + storage.OwnerID = ownerID.String + + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get default storage by cluster: %w", err) + } + + if err := json.Unmarshal(configJSON, &storage.Config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return storage, nil +} + // Update 更新存储后端 func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error { storage.UpdatedAt = time.Now() @@ -221,8 +307,8 @@ func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageB query := ` UPDATE storage_backends - SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, updated_at = $7 - WHERE id = $8 + SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, cluster_id = $7, updated_at = $8 + WHERE id = $9 ` result, err := r.db.conn.ExecContext(ctx, query, @@ -232,6 +318,7 @@ func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageB storage.Description, storage.IsDefault, storage.IsShared, + sqlNullString(storage.ClusterID), storage.UpdatedAt, storage.ID, ) @@ -276,7 +363,7 @@ func (r *StorageRepository) Delete(ctx context.Context, id string) error { // List 列出所有存储后端(管理员用) func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) { query := ` - SELECT id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at + SELECT id, workspace_id, cluster_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at FROM storage_backends ORDER BY workspace_id, name ` @@ -296,9 +383,11 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke for rows.Next() { storage := &entity.StorageBackend{} var configJSON []byte + var wsID, clusterID sql.NullString err := rows.Scan( &storage.ID, - &storage.WorkspaceID, + &wsID, + &clusterID, &storage.OwnerID, &storage.Name, &storage.Type, @@ -312,6 +401,8 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke if err != nil { return nil, fmt.Errorf("failed to scan storage: %w", err) } + storage.WorkspaceID = wsID.String + storage.ClusterID = clusterID.String if err := json.Unmarshal(configJSON, &storage.Config); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } diff --git a/backend/internal/bootstrap/config.go b/backend/internal/bootstrap/config.go index 1d2e7d0..6aae088 100644 --- a/backend/internal/bootstrap/config.go +++ b/backend/internal/bootstrap/config.go @@ -5,14 +5,15 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // BootstrapConfig 预注入配置 type BootstrapConfig struct { - Enabled bool `json:"enabled"` - Users []UserSeed `json:"users"` - Registries []RegistrySeed `json:"registries"` - Clusters []ClusterSeed `json:"clusters"` + Enabled bool `json:"enabled"` + Users []UserSeed `json:"users"` + Registries []RegistrySeed `json:"registries"` + Clusters []ClusterSeed `json:"clusters"` } // UserSeed 用户预注入数据 @@ -45,11 +46,11 @@ type ClusterSeed struct { // LoadBootstrapConfig 加载预注入配置 // 支持从文件或环境变量加载 -// +// // 加载优先级: // 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级) // 2. Mock 模式: 配置文件 config/bootstrap.json -// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据 +// 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取 func LoadBootstrapConfig() (*BootstrapConfig, error) { // 1. 优先从环境变量加载 if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" { @@ -62,7 +63,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) { // 2. 检查适配器模式 adapterMode := os.Getenv("ADAPTER_MODE") - + // Mock 模式: 使用配置文件(假数据) if adapterMode == "mock" { configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE") @@ -89,49 +90,87 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) { return &config, nil } - // 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据 + // 3. 真实模式 (mode 1, mode 2): 从 .env 读取 return GetDefaultBootstrapConfig(), nil } -// GetDefaultBootstrapConfig 获取默认的预注入配置(示例) +// GetDefaultBootstrapConfig 从 .env 加载 bootstrap 数据。 +// 支持 BOOTSTRAP_CLUSTERS (逗号分隔的集群名) 以及每个集群的 +// BOOTSTRAP_CLUSTER__HOST, _CA, _CERT, _KEY, _DESC。 +// 支持 BOOTSTRAP_REGISTRY_* 环境变量。 +// 支持 BOOTSTRAP_ADMIN_USER/PASS/EMAIL。 func GetDefaultBootstrapConfig() *BootstrapConfig { + // Load clusters from .env (comma-separated list of cluster names) + clusterStr := os.Getenv("BOOTSTRAP_CLUSTERS") + var clusterSeeds []ClusterSeed + if clusterStr != "" { + clusterNames := strings.Split(clusterStr, ",") + for _, name := range clusterNames { + name = strings.TrimSpace(name) + if name == "" { + continue + } + key := sanitizeEnvKey(name) + host := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_HOST") + ca := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CA") + cert := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_CERT") + keyData := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_KEY") + desc := os.Getenv("BOOTSTRAP_CLUSTER_" + key + "_DESC") + if host != "" { + clusterSeeds = append(clusterSeeds, ClusterSeed{ + Name: name, + Host: host, + Description: desc, + CAData: ca, + CertData: cert, + KeyData: keyData, + }) + } + } + } + + // Load registry from .env + var registrySeeds []RegistrySeed + regName := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_NAME")) + regURL := strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_URL")) + if regName != "" && regURL != "" { + registrySeeds = append(registrySeeds, RegistrySeed{ + Name: regName, + URL: regURL, + Description: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_DESC")), + Username: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_USER")), + Password: strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_PASS")), + Insecure: strings.ToLower(strings.TrimSpace(os.Getenv("BOOTSTRAP_REGISTRY_INSECURE"))) == "true", + }) + } + + // Load users from .env + var userSeeds []UserSeed + adminUser := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_USER")) + adminPass := strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_PASS")) + if adminUser != "" { + userSeeds = append(userSeeds, UserSeed{ + Username: adminUser, + Password: adminPass, + Email: strings.TrimSpace(os.Getenv("BOOTSTRAP_ADMIN_EMAIL")), + }) + } + return &BootstrapConfig{ - Enabled: true, - Users: []UserSeed{ - { - Username: "admin", - Password: "admin123", - Email: "admin@example.com", - }, - }, - Registries: []RegistrySeed{ - { - Name: "harbor-bwgdi", - URL: "https://harbor.bwgdi.com", - Description: "BWGDI Harbor Registry", - Username: "admin", - Password: "BWGDIP@ssw0rd1401#", - Insecure: false, - }, - }, - Clusters: []ClusterSeed{ - { - Name: "cluster1", - Host: "https://10.6.14.123:6443", - Description: "K3s Cluster 1", - CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTlRVME9ETTJOemt3SGhjTk1qVXdPREU0TURJeU1URTVXaGNOTXpVd09ERTJNREl5TVRFNQpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTlRVME9ETTJOemt3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFTaVBJUW5LZXR2VjQ3cHUyLytMV1lZaGJjbUY3V3RZQnArOGxDaUVKdkcKaFAyaE5BWVVmZDUrRnN5VVN3bDBTV3NoT3BORmRMc0NzY3pISkhycUpWYUVvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVTlCa3lhSGpPVG1RM29LYWlOaXFmCjVwZTF4L293Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnTzR4M3EyNmhhL1Z0NTRCT1Awc1hVNGt5ckVpNDR6TUcKc0d0Z25LY0NLbk1DSVFEcVhsSzBqSGNKSVE2bTRWanRub0VQWGdzQ2JrdW45WmxvVmxhbWtPNXAzZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", - CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrakNDQVRlZ0F3SUJBZ0lJVjVQT1FRblJoSGd3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOelUxTkRnek5qYzVNQjRYRFRJMU1EZ3hPREF5TWpFeE9Wb1hEVEkyTURneApPREF5TWpFeE9Wb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJMTjcrbjNXRDY0TThTMEEKT1Bpd2hReFZRNWdLTStRTk11REFzSlM1UVZFdTIyajZwaFlQYTNyQWFLU1hnZE1EdVYvbTRUamxTQmxCM2dJQwpnZW5wdTc2alNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCVGlxTWRFM0xYbElwVHRiREJnN0ZVcmV1NHVVREFLQmdncWhrak9QUVFEQWdOSkFEQkcKQWlFQXRPQ0s4ZmdzZmxhaTczcXdXMkhQbWM2bDVXNmR2L1BzNGhHNDZFRkV0VlFDSVFDenFkQitkZnFiWkJ5cwpNUm0zbDU1N3pNOFBNcDhRUE5lVFdiM0VoOEdtVGc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlCZGpDQ0FSMmdBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwClpXNTBMV05oUURFM05UVTBPRE0yTnprd0hoY05NalV3T0RFNE1ESXlNVEU1V2hjTk16VXdPREUyTURJeU1URTUKV2pBak1TRXdId1lEVlFRRERCaHJNM010WTJ4cFpXNTBMV05oUURFM05UVTBPRE0yTnprd1dUQVRCZ2NxaGtqTwpQUUlCQmdncWhrak9QUU1CQndOQ0FBU3JxQzd2RUhKYzQzUThIWG5MT0VQeXkyM0tYZzlHOVkycTJUaVFLMGhoCkJvNnh1WUxDMTFSWkhGNC85NGZJZitZa3BCcmRpcFFNTjRSaVVrUGZzM28zbzBJd1FEQU9CZ05WSFE4QkFmOEUKQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVU0cWpIUk55MTVTS1U3V3d3WU94VgpLM3J1TGxBd0NnWUlLb1pJemowRUF3SURSd0F3UkFJZ041WmJQaEs4YkwxWllmcStGTVNNbkFCdEgzRSsxcnFoClpRUHY4UWM3S09nQ0lCMWhBclM5SXhKU1dYYlV3ZWE4WU0yVUNEMlplYTVxMHJMQnd4SHFqb3RjCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", - KeyData: "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUpuM2dPd0lBNzJGMXE2dkhvMHdDRk1RS0VXVmVnejlQYy9NRFhVVDU5c3pvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFczN2NmZkWVByZ3p4TFFBNCtMQ0ZERlZEbUFvejVBMHk0TUN3bExsQlVTN2JhUHFtRmc5cgplc0JvcEplQjB3TzVYK2JoT09WSUdVSGVBZ0tCNmVtN3ZnPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo=", - }, - { - Name: "cluster2", - Host: "https://10.6.80.12:6443", - Description: "Kubernetes Cluster 2", - CAData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWCtGQVJITzJWdVl3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHpOVEV3TWpnd016VXhOVE5hTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUROdFJSeG5JYVU2MS93UHVWNkpiR0hLaWtaZWVmYXlNOEFzVHRQeXQwaU5BaFgvVWNUT1pSVWYyZmUKTXBKSFNDdy9QQjJ2d1dCZDB2OVBEVWZ6RTYxL0lKcmhWZU54NmRxK0VPdVFqRmI2TlMvbkpiWmpXVFoyRFhBRQpkS1lwaGpXWGV3dWVuK0htTjlyK2tIZGlORVdmc0xDb1hWOFFMSmVRZXF4NHY2eTFkaEE1Ly9sdGxRV0ZsN2ZFCkRzeUpQb05tQmhzSy9SNEpYVDZ4Q0NqYmJmRFF6OE1hTXA0aWZnRW9ac0R6T2RlK3ZDL3diMEcxVmlpL1FjOEEKSCtSb2tJUkI2MTZqM0VjOWhsd1V4UjNyZThqOGFFdDJob1BkbTVhekt1YjQ0LzlKc3VaU1BWR0FYVXVjekQyawpYUU5UOWErOVl4RXZJZ0psdFpuRGVYSjZmeTFqQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSVEo2WWgwQ3lWVDRGNEhJUSszYWVhQzZzMUlUQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1pZM0xuUDl4Qgp1MjJaMENtazdiNUI2T1RtRS9obWlNRDNXY3kyb3RpcVhvZUE1VENRWnZxUk1PTk1NR3NCZFYza3FRRFhyaVR1CkQ4MDdaL3Q3SlAvOGo1RmRncDBCbkpoOUtlQkhaeVBybWFQNW9veFg4VWhFZHF0bWdsTUtBSk0xVmpKTExZNUwKMUcyRVNWa09NKytTSkV5MGJMbU9LM3M2YUI1L05pK3BVVS82Z1ZFNDFIZnh1SEJVYUtrRXNJR1d0WnNxbEY1cwp1RVAzZnY0ZmJRZVAxTmEvRlNaSmh4NlBybEdjZlE2Vmh6a1haY2Q1RExKMHZHbHZoTGdwREowdUVsUEd6NU5KCldFelVJZ3BGV25UMUd4TlhuNm02Sm9oMmNoWU5oQ25KOGZCS0Q4elozei9LdExCa2JwMDdMRlgwbzhXQUhEQmcKK1A4cjUwTm5IT3FHCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", - CertData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURLVENDQWhHZ0F3SUJBZ0lJWUlIcnhuOXYvOTR3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRFd016QXdNelEyTlROYUZ3MHlOakV3TXpBd016VXhOVE5hTUR3eApIekFkQmdOVkJBb1RGbXQxWW1WaFpHMDZZMngxYzNSbGNpMWhaRzFwYm5NeEdUQVhCZ05WQkFNVEVHdDFZbVZ5CmJtVjBaWE10WVdSdGFXNHdnZ0VpTUEwR0NTcUdTSWIzRFFFQkFRVUFBNElCRHdBd2dnRUtBb0lCQVFEd0NGWW0KY1JldG5xWjJBR21FUGJ2L1pRVzdrSzFKNHlBUmI2ODVlNEl5QjQ2OXdKOFVtd1crOXB2OWNsVm5YV3pnQkY3WQpnbkIyNi9DTWtqOVpnRkhOaWFPK3RXcXg3cHJKTkdDaHhiY29VMDZzQUIwR3MvUkVHK3VYMnFZa3RnVHpRNWFrCitGKzZrZElRek5VdnpwWFUzUFlHcDFEcGlzNWxZNFYzMkhnSkRaZkMrRzlpT1ROd1dtTzV3bGF1K1lsQkRGTVIKS2tnVFo1MDY5OXl5NWxnUlRoaTczSG1hUCtLWGdIT0QrNkNmeUZ6Ty80KzdLaExjanZpTGFUVjBjNGkzYkxidQo0K0llU2pwMEpxU2lxQlFtRHhHRitYMndCSkNiRVZObWJrd0hCVlh5eXlxdGJWV2dibEN6SWJ0UDBadHE3RUMwClo0WkNDemc5RFNqRGQwZWZBZ01CQUFHalZqQlVNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUsKQmdnckJnRUZCUWNEQWpBTUJnTlZIUk1CQWY4RUFqQUFNQjhHQTFVZEl3UVlNQmFBRkZNbnBpSFFMSlZQZ1hnYwpoRDdkcDVvTHF6VWhNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUFzTHJBMEhFOVNGNHAvSzBQejlVdFZLdk9rCjNUaEZ0ODZGTGlWNEJMcTZ5RSt1aHdHazk0b3p1Y3c1T2h1WEduTWFaUlFMYnliS3pJcjQvUUNqQVQ5eFVURWQKSFQ4c1c1UEhHMm5lbGJRckFNdVhRaFpXdlZTRmZ6Tk5GZG0rNStzdnVXajVtMklyNXNYRURlV2dBdmNLd3k2cwpVUjIxSmdtVXZHSFFtTVVZYWpnYW8wS3NjQmtNOEpZekFKdXZWdkJtTytwdzN5T2hVVmMyY0JnV0gybmx3L3RLCjZRR0Y0ZUZPRnJaYzM5UHp2NmlVOHFBYnNrQlVTVlhuaXg3dTNZUzFwTHNuZitSY0U0MmR1RzV4Nll3UFBlb28KRXBwWVluZ1R5TlpKKzVGaHVZdTUwMDJsQm1DV3JrSkxEek5NWlR3ai9DeG52ekVnSWJPWFpndnRpSXhpCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K", - KeyData: "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcFFJQkFBS0NBUUVBOEFoV0puRVhyWjZtZGdCcGhEMjcvMlVGdTVDdFNlTWdFVyt2T1h1Q01nZU92Y0NmCkZKc0Z2dmFiL1hKVloxMXM0QVJlMklKd2R1dndqSkkvV1lCUnpZbWp2clZxc2U2YXlUUmdvY1czS0ZOT3JBQWQKQnJQMFJCdnJsOXFtSkxZRTgwT1dwUGhmdXBIU0VNelZMODZWMU56MkJxZFE2WXJPWldPRmQ5aDRDUTJYd3ZodgpZamt6Y0ZwanVjSldydm1KUVF4VEVTcElFMmVkT3ZmY3N1WllFVTRZdTl4NW1qL2lsNEJ6Zy91Z244aGN6ditQCnV5b1MzSTc0aTJrMWRIT0l0MnkyN3VQaUhrbzZkQ2Frb3FnVUpnOFJoZmw5c0FTUW14RlRabTVNQndWVjhzc3EKclcxVm9HNVFzeUc3VDlHYmF1eEF0R2VHUWdzNFBRMG93M2RIbndJREFRQUJBb0lCQUFxSWt4OUV2MEZEUVJMVQptY3pQMkx3d2RydndjV3BZcVVPYW54bnFyWi84Yk9zdTFNeFdzVDNjSEtSV3JDREpITW9INXhHaFI4WXdQSEl1CnlORG9ySzVVWi9jcWh2QWdCSExuOVlXajQ1SEZkaUplTHVmb1pjUEhaZU5ZR1FwclluUTZkeFh1UUdVem1RQmIKdk05SVJaTDl6MTRqWVkyZUpjaVZRWG9zNmJlYjUxYjgxNGljMTg1RHNtK2RhekRuNG14M2tNT0lueFR2K01pNQpxSWx5OU8vQURIaWpNd2taNVY5K3grSlpxM3Exc09SeTBKcUUwd1czbFcwQnFxSWRGRFRSelAvMFdiVGZZdDU3CmlRNjJySnhEN1RGNzR3Ni8xc3VqalU3Y2VsK1ltdTRvRFZjb05pOGdoTE1UZXE1OWpPMk1xR1FqMU5HUHRuTHkKb0hFOUs4RUNnWUVBOVRiQ3VEUlBtVDFmN0MwUldYUkJnejlENWhhRExkaS82aitjMGx5amR0TjkyR2JHdFNFMQozVVIvc2dsRit3bVliWmJmNExqUnpibnNZTGFleHRtakpzWXdFK0t4SSt3SEloSElPRFFaSTBaT08vMTJYdm1oCjB4dDdUNmNTVTZZSHZEbkp4WkpFaGt3TjBwL1ZoSHZMZFZMWmd3ZnNtQWlVekNTTVBmaUkySmtDZ1lFQStwYzcKTUJ0ZFNBZnd5cElMaUR6dis2WjFBQnVrWUphWnFQTk9IRGdLeElRNVJEQVZ5K3hSQXJWQ1V3RE5WdDJtTGJHUQpHZysvWXl4ZllEd2dSYTIxMUJDL0pUU3E4S1dHYVdXM0h2Z0VmMk54cVVIckNkT3VGZGhqdWkrMlRBdEdBb0w1CjluSGx3TXBZVVpydjF6dENCRmx4L1ZYd3NxUGZ6K2l5ZG1CVUxQY0NnWUVBcFM5Q2RMd29jdDQ1WSt2b0tBNTgKbzJGVzZBUjZVY1FWWkVOOTdPZWk1a1VLSFdEK3NyMndmMkhKYzdGemh1eXIxZ2N3d1QwL2VBcXJCV3VBQWd4UwpMNmlLY3ByZklZZTZObVVzTDFCSkxzNEpuYmZjcVpZWVFSSGVPNFljZm1UMkNRSVV2aGNPT2ptNWhnMU4xSFZnClZhUitDaHFvY3JJMUtsL2thVXFuUk9FQ2dZRUF5ZWx0RVhnYkUxMENrZFpYWUhEcFZUVnNkS2ZSTE5wcitZd0IKMWc3NTdobzBJbE0wWE5tTzlNV2tLVWt1S3QzeGRrUHFQbldOMnBUNFRJeGwzSDc1VVdRbEFBK041TlVhbG5ZVQp0T2xXaG1aVVFQTVNOUnJRM0YwOURkby80c242b1M5enhUVkUwTEM1dFJkSVJYNUQxVWxVNWJHSGZnazQzMGM1CjlOUHRQMFVDZ1lFQXk1L05hZXJlZDlQSDcyVzNDNW1UQy9jbEQxdUdmZXdPVkFkdko1eldlMDh4Q01CcEpya1QKU3dKM3NZOXYyaEdwSUxYZnU5YnppL0RWaW1sZk5MNkZBV2VaR3BCYm1qTHBEcUxWRzdhcUNHQVcvRG9iNmVlWApweEFiQTBLaUhoaE9sdUdONHdkbFdQRzNWdTlZNXZIb3RBNW1iZlRpaHhUYTlEZWRkZXlkNC9RPQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=", - }, - }, + Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0, + Users: userSeeds, + Registries: registrySeeds, + Clusters: clusterSeeds, } } +// sanitizeEnvKey converts "my-cluster" to "MY_CLUSTER" for env var names. +func sanitizeEnvKey(name string) string { + s := strings.Map(func(r rune) rune { + if r == '-' || r == ' ' { + return '_' + } + return r + }, name) + return strings.ToUpper(s) +} \ No newline at end of file diff --git a/backend/internal/domain/entity/instance.go b/backend/internal/domain/entity/instance.go index e20c6aa..1ca8936 100644 --- a/backend/internal/domain/entity/instance.go +++ b/backend/internal/domain/entity/instance.go @@ -1,9 +1,12 @@ package entity import ( + "log" "strings" "time" "unicode" + + "gopkg.in/yaml.v3" ) // InstanceStatus 实例状态 @@ -103,9 +106,31 @@ func (i *Instance) SetValues(values map[string]interface{}) { i.UpdatedAt = time.Now() } -// SetValuesYAML 设置 YAML 格式的 Values -func (i *Instance) SetValuesYAML(yaml string) { - i.ValuesYAML = yaml +// SetValuesYAML 设置 YAML 格式的 Values 并解析到 Values map +func (i *Instance) SetValuesYAML(yamlStr string) { + i.ValuesYAML = yamlStr + if yamlStr == "" { + return + } + // 解析 YAML 到 map,确保 Helm 客户端能正确使用 + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlStr), &parsed); err != nil { + log.Printf("[SetValuesYAML] WARNING: failed to parse YAML for instance %s: %s, yaml=%q", i.Name, err, yamlStr) + return + } + if parsed == nil { + return + } + // Merge into existing Values (user-provided takes precedence) + if i.Values == nil { + i.Values = make(map[string]interface{}) + } + for k, v := range parsed { + // Only set if not already present (Values map takes precedence over YAML fallback) + if _, exists := i.Values[k]; !exists { + i.Values[k] = v + } + } i.UpdatedAt = time.Now() } diff --git a/backend/internal/domain/entity/storage.go b/backend/internal/domain/entity/storage.go index 740d745..41dce86 100644 --- a/backend/internal/domain/entity/storage.go +++ b/backend/internal/domain/entity/storage.go @@ -43,6 +43,7 @@ type HostPathConfig struct { type StorageBackend struct { ID string WorkspaceID string + ClusterID string // 关联的 cluster,NULL 表示 workspace/shared 级别 OwnerID string Name string Type StorageType @@ -70,6 +71,13 @@ func NewStorageBackend(workspaceID, ownerID, name string, storageType StorageTyp } } +// NewClusterStorageBackend 创建 cluster 级别的存储后端 +func NewClusterStorageBackend(workspaceID, clusterID, ownerID, name string, storageType StorageType, config StorageConfig) *StorageBackend { + storage := NewStorageBackend(workspaceID, ownerID, name, storageType, config) + storage.ClusterID = clusterID + return storage +} + // Validate 验证存储后端数据 func (s *StorageBackend) Validate() error { if s.Name == "" { diff --git a/backend/internal/domain/entity/workspace.go b/backend/internal/domain/entity/workspace.go index b18c802..8eea4f4 100644 --- a/backend/internal/domain/entity/workspace.go +++ b/backend/internal/domain/entity/workspace.go @@ -9,17 +9,19 @@ type Workspace struct { ID string Name string Description string - CreatedBy string // 创建者用户 ID + ClusterIDs []string // 关联的集群 ID 列表 + CreatedBy string // 创建者用户 ID CreatedAt time.Time UpdatedAt time.Time } // NewWorkspace 创建新工作空间 -func NewWorkspace(name, description, createdBy string) *Workspace { +func NewWorkspace(name, description, createdBy string, clusterIDs []string) *Workspace { now := time.Now() return &Workspace{ Name: name, Description: description, + ClusterIDs: clusterIDs, CreatedBy: createdBy, CreatedAt: now, UpdatedAt: now, diff --git a/backend/internal/domain/repository/storage_repository.go b/backend/internal/domain/repository/storage_repository.go index 9454d01..07464b0 100644 --- a/backend/internal/domain/repository/storage_repository.go +++ b/backend/internal/domain/repository/storage_repository.go @@ -25,6 +25,12 @@ type StorageRepository interface { // GetDefault 获取 workspace 的默认存储后端 GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) + // GetByCluster 获取 cluster 关联的存储后端列表 + GetByCluster(ctx context.Context, clusterID string) ([]*entity.StorageBackend, error) + + // GetDefaultByCluster 获取 cluster 的默认存储后端 + GetDefaultByCluster(ctx context.Context, clusterID string) (*entity.StorageBackend, error) + // Update 更新存储后端 Update(ctx context.Context, storage *entity.StorageBackend) error diff --git a/backend/internal/domain/service/cluster_service.go b/backend/internal/domain/service/cluster_service.go index 01f7022..0371e85 100644 --- a/backend/internal/domain/service/cluster_service.go +++ b/backend/internal/domain/service/cluster_service.go @@ -175,8 +175,8 @@ func (s *ClusterService) createRestConfig(cluster *entity.Cluster) (*rest.Config kubeconfig = ".kube/config" } // 尝试从文件加载 kubeconfig - if _, err := os.Stat(kubeconfig); err == nil { - return clientcmd.BuildConfigFromFlags("", kubeconfig) + if _, err := os.Stat(kubeconfig); err != nil { + return nil, fmt.Errorf("no valid credentials found for cluster %s (no cert/key/token, and kubeconfig file not found: %s)", cluster.Name, kubeconfig) } return clientcmd.BuildConfigFromFlags("", kubeconfig) } diff --git a/backend/internal/domain/service/instance_service.go b/backend/internal/domain/service/instance_service.go index 05f757b..06d0a89 100644 --- a/backend/internal/domain/service/instance_service.go +++ b/backend/internal/domain/service/instance_service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "os" "path/filepath" "strings" @@ -16,12 +17,13 @@ import ( // InstanceService Helm 实例管理领域服务 type InstanceService struct { - instanceRepo repository.InstanceRepository - clusterRepo repository.ClusterRepository - registryRepo repository.RegistryRepository - helmClient repository.HelmClient - ociClient repository.OCIClient - entryClient repository.InstanceEntryClient + instanceRepo repository.InstanceRepository + clusterRepo repository.ClusterRepository + registryRepo repository.RegistryRepository + helmClient repository.HelmClient + ociClient repository.OCIClient + entryClient repository.InstanceEntryClient + storageService *StorageService // for layered storage config resolution } // NewInstanceService 创建实例服务 @@ -34,15 +36,21 @@ func NewInstanceService( entryClient repository.InstanceEntryClient, ) *InstanceService { return &InstanceService{ - instanceRepo: instanceRepo, - clusterRepo: clusterRepo, - registryRepo: registryRepo, - helmClient: helmClient, - ociClient: ociClient, - entryClient: entryClient, + instanceRepo: instanceRepo, + clusterRepo: clusterRepo, + registryRepo: registryRepo, + helmClient: helmClient, + ociClient: ociClient, + entryClient: entryClient, + storageService: nil, // set via SetStorageService for layered storage } } +// SetStorageService 设置存储服务(用于分层存储配置解析) +func (s *InstanceService) SetStorageService(storageService *StorageService) { + s.storageService = storageService +} + const chartCacheDir = "/tmp/charts" func (s *InstanceService) chartArchivePath(instance *entity.Instance) string { @@ -89,6 +97,20 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I return entity.ErrInstanceExists } + // ===== 分层存储配置解析 ===== + // Priority: workspace-level default > cluster-level default > shared default + if s.storageService != nil && instance.WorkspaceID != "" { + resolution, err := s.storageService.ResolveStorageConfig(ctx, instance.ClusterID, instance.WorkspaceID) + if err == nil && resolution != nil && resolution.Storage != nil { + // Merge resolved storage values into instance.Values + if instance.Values == nil { + instance.Values = make(map[string]interface{}) + } + // User override takes highest priority (already set), so we only set if not already present + mergeStorageToValues(instance.Values, resolution.Storage) + } + } + instance.BeginOperation(entity.OperationInstall, "Preparing installation") // 先写入数据库,记录 pending 状态 @@ -104,7 +126,16 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I } // 异步执行 Helm 安装并监控状态 - go s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance) + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("[goroutine-panic] instanceID=%s panic=%v", instance.ID, r) + } + }() + log.Printf("[goroutine-start] instanceID=%s name=%s cluster=%s", instance.ID, instance.Name, cluster.Name) + s.executeAndSyncInstall(context.Background(), instance.ID, cluster, registry, instance) + log.Printf("[goroutine-done] instanceID=%s", instance.ID) + }() // 立即返回,状态同步由后台任务处理 return nil @@ -286,8 +317,10 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in // executeAndSyncInstall 异步执行安装并监控状态 func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) { + log.Printf("[install-start] instanceID=%s values=%v", instanceID, instance.Values) // 执行 Helm 安装 if err := s.helmClient.Install(ctx, cluster, instance); err != nil { + log.Printf("[install-fail] instanceID=%s err=%v", instanceID, err) // 更新实例状态为失败 instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID) if updateErr == nil && instance != nil { @@ -296,6 +329,7 @@ func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID } return } + log.Printf("[install-ok] instanceID=%s revision=%d", instanceID, instance.Revision) // 安装成功后,同步状态 s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall) @@ -472,3 +506,48 @@ func (s *InstanceService) syncInstanceStatus(ctx context.Context, instanceID str _ = s.instanceRepo.Update(ctx, instance) } } + +// mergeStorageToValues 将存储配置 merge 到 Helm values +// 只覆盖 nil/空的字段,保留用户已设置的 values +func mergeStorageToValues(values map[string]interface{}, storage *entity.StorageBackend) { + if storage == nil || values == nil { + return + } + + persistence := make(map[string]interface{}) + + switch storage.Type { + case entity.StorageTypeNFS: + if storage.Config.NFS != nil { + persistence["type"] = "nfs" + persistence["nfs"] = map[string]interface{}{ + "server": storage.Config.NFS.Server, + "path": storage.Config.NFS.Path, + } + // Helm common chart labels + persistence["mountOptions"] = []string{"rw", "relatime", "vers=3"} + persistence["reclaimPolicy"] = "Retain" + } + case entity.StorageTypePV: + if storage.Config.PV != nil { + persistence["type"] = "persistentVolumeClaim" + persistence["storageClass"] = storage.Config.PV.StorageClassName + persistence["size"] = storage.Config.PV.Capacity + persistence["accessMode"] = storage.Config.PV.AccessModes + } + case entity.StorageTypeHostPath: + if storage.Config.HostPath != nil { + persistence["type"] = "hostPath" + persistence["hostPath"] = map[string]interface{}{ + "path": storage.Config.HostPath.Path, + } + } + } + + // Only merge if key doesn't already exist and has a value + for key, val := range persistence { + if _, exists := values[key]; !exists && val != nil { + values[key] = val + } + } +} diff --git a/backend/internal/domain/service/storage_service.go b/backend/internal/domain/service/storage_service.go index bcd1bb5..0c15d36 100644 --- a/backend/internal/domain/service/storage_service.go +++ b/backend/internal/domain/service/storage_service.go @@ -3,6 +3,8 @@ package service import ( "context" "errors" + "fmt" + "strings" "github.com/ocdp/cluster-service/internal/domain/entity" "github.com/ocdp/cluster-service/internal/domain/repository" @@ -13,6 +15,13 @@ var ( ErrStorageExists = errors.New("storage backend already exists") ) +// StorageResolution 存储分层解析结果 +type StorageResolution struct { + Storage *entity.StorageBackend // 最终选中的 storage + ValuesYAML string // 转换为 YAML 的 values + Source string // 来源: "workspace", "cluster", "shared" +} + // StorageService 存储后端领域服务 type StorageService struct { storageRepo repository.StorageRepository @@ -33,14 +42,20 @@ func (s *StorageService) Create( config entity.StorageConfig, description string, isDefault, isShared bool, + clusterID string, ) (*entity.StorageBackend, error) { - // 检查名称是否已存在 + // 检查名称是否已存在(同一 workspace 或同一 cluster 下不能重复) existing, _ := s.storageRepo.GetByName(ctx, workspaceID, name) if existing != nil { return nil, ErrStorageExists } - storage := entity.NewStorageBackend(workspaceID, ownerID, name, storageType, config) + var storage *entity.StorageBackend + if clusterID != "" { + storage = entity.NewClusterStorageBackend(workspaceID, clusterID, ownerID, name, storageType, config) + } else { + storage = entity.NewStorageBackend(workspaceID, ownerID, name, storageType, config) + } storage.Description = description storage.IsDefault = isDefault storage.IsShared = isShared @@ -113,4 +128,81 @@ func (s *StorageService) Delete(ctx context.Context, id string) error { // List 列出所有存储后端(管理员用) func (s *StorageService) List(ctx context.Context) ([]*entity.StorageBackend, error) { return s.storageRepo.List(ctx) +} + +// ResolveStorageConfig 分层解析存储配置 +// 优先级:User Override > Workspace Default > Cluster Default > Shared Default +func (s *StorageService) ResolveStorageConfig( + ctx context.Context, + clusterID, workspaceID string, +) (*StorageResolution, error) { + // 1. 查找 workspace-level 默认存储 + if workspaceID != "" { + wsStorage, err := s.storageRepo.GetDefault(ctx, workspaceID) + if err == nil && wsStorage != nil { + yaml, _ := storageToValuesYAML(wsStorage) + return &StorageResolution{ + Storage: wsStorage, + ValuesYAML: yaml, + Source: "workspace", + }, nil + } + } + + // 2. 查找 cluster-level 默认存储 + if clusterID != "" { + clusterStorage, err := s.storageRepo.GetDefaultByCluster(ctx, clusterID) + if err == nil && clusterStorage != nil { + yaml, _ := storageToValuesYAML(clusterStorage) + return &StorageResolution{ + Storage: clusterStorage, + ValuesYAML: yaml, + Source: "cluster", + }, nil + } + } + + // 3. 查找 shared 默认存储 + sharedStorages, err := s.storageRepo.GetShared(ctx) + if err == nil { + for _, s := range sharedStorages { + if s.IsDefault { + yaml, _ := storageToValuesYAML(s) + return &StorageResolution{ + Storage: s, + ValuesYAML: yaml, + Source: "shared", + }, nil + } + } + } + + return nil, nil +} + +// storageToValuesYAML 将 storage config 转换为 values.yaml 格式 +func storageToValuesYAML(storage *entity.StorageBackend) (string, error) { + if storage == nil { + return "", nil + } + + switch storage.Type { + case entity.StorageTypeNFS: + if storage.Config.NFS != nil { + // Format as nfs-server/path storageClass so Helm charts like bitnami/nginx + // can use: persistence.storageClass: "nfs-server/path" + nfsSC := fmt.Sprintf("nfs-%s-%s", storage.Config.NFS.Server, strings.TrimPrefix(storage.Config.NFS.Path, "/")) + return fmt.Sprintf("persistence:\n enabled: true\n storageClass: \"%s\"\n existingClaim: \"\"\n mountOptions:\n - hard\n - nfsvers=4.1\n dataSource: {}\n# NFS Server: %s\n# NFS Path: %s", nfsSC, storage.Config.NFS.Server, storage.Config.NFS.Path), nil + } + case entity.StorageTypePV: + if storage.Config.PV != nil { + return fmt.Sprintf("persistence:\n enabled: true\n storageClass: \"%s\"\n size: %s\n accessModes: %v\n existingClaim: \"\"", storage.Config.PV.StorageClassName, storage.Config.PV.Capacity, storage.Config.PV.AccessModes), nil + } + case entity.StorageTypeHostPath: + if storage.Config.HostPath != nil { + return fmt.Sprintf("persistence:\n enabled: true\n hostPath: \"%s\"\n existingClaim: \"\"", storage.Config.HostPath.Path), nil + } + } + + return "", nil } \ No newline at end of file diff --git a/backend/internal/domain/service/workspace_service.go b/backend/internal/domain/service/workspace_service.go index 389d05b..481237c 100644 --- a/backend/internal/domain/service/workspace_service.go +++ b/backend/internal/domain/service/workspace_service.go @@ -26,19 +26,31 @@ func NewWorkspaceService( } } -// Create 创建工作空间 -func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string) (*entity.Workspace, error) { +// Create 创建工作空间(支持 cluster_ids 和初始配额) +func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string, clusterIDs []string, quotas map[entity.ResourceType]struct { + HardLimit float64 + SoftLimit float64 +}) (*entity.Workspace, error) { // 检查名称是否已存在 existing, _ := s.workspaceRepo.GetByName(ctx, name) if existing != nil { return nil, entity.ErrWorkspaceExists } - workspace := entity.NewWorkspace(name, description, createdBy) + workspace := entity.NewWorkspace(name, description, createdBy, clusterIDs) if err := s.workspaceRepo.Create(ctx, workspace); err != nil { return nil, err } + // 如果提供了配额,创建它们 + for resourceType, config := range quotas { + quota := entity.NewWorkspaceQuota(workspace.ID, resourceType, config.HardLimit, config.SoftLimit) + if err := s.quotaRepo.Create(ctx, quota); err != nil { + // 记录错误但不阻止工作空间创建 + continue + } + } + return workspace, nil } diff --git a/backend/scripts/init-db.sql b/backend/scripts/init-db.sql index 267c90b..6f007de 100644 --- a/backend/scripts/init-db.sql +++ b/backend/scripts/init-db.sql @@ -142,6 +142,8 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_name ON workspaces(name); CREATE TABLE IF NOT EXISTS storage_backends ( id VARCHAR(36) PRIMARY KEY, workspace_id VARCHAR(36), + owner_id VARCHAR(36), + cluster_id VARCHAR(36), name VARCHAR(255) NOT NULL, type VARCHAR(50) NOT NULL, config JSONB NOT NULL, @@ -154,6 +156,8 @@ CREATE TABLE IF NOT EXISTS storage_backends ( ); CREATE INDEX IF NOT EXISTS idx_storage_workspace ON storage_backends(workspace_id); +CREATE INDEX IF NOT EXISTS idx_storage_cluster ON storage_backends(cluster_id); +CREATE INDEX IF NOT EXISTS idx_storage_default_cluster ON storage_backends(cluster_id, is_default) WHERE cluster_id IS NOT NULL; -- ===== Chart References 表 ===== CREATE TABLE IF NOT EXISTS chart_references ( @@ -183,7 +187,7 @@ CREATE TABLE IF NOT EXISTS values_templates ( is_default BOOLEAN DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(workspace_id, chart_reference_id, name) + UNIQUE(workspace_id, chart_reference_id, name, version) ); CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id); diff --git a/backend/scripts/migrations/20250418_add_cluster_storage.sql b/backend/scripts/migrations/20250418_add_cluster_storage.sql new file mode 100644 index 0000000..760d962 --- /dev/null +++ b/backend/scripts/migrations/20250418_add_cluster_storage.sql @@ -0,0 +1,15 @@ +-- Migration: Add cluster_id to storage_backends for layered storage config +-- This enables storage backends to be associated with specific clusters +-- Priority order: User Override > Workspace Default > Cluster Default > Shared Storage + +-- Add cluster_id column to storage_backends table +ALTER TABLE storage_backends ADD COLUMN IF NOT EXISTS cluster_id VARCHAR(36) REFERENCES clusters(id) ON DELETE SET NULL; + +-- Index for faster lookups by cluster +CREATE INDEX IF NOT EXISTS idx_storage_backends_cluster ON storage_backends(cluster_id); + +-- Composite index for cluster + default storage lookup +CREATE INDEX IF NOT EXISTS idx_storage_backends_default_cluster ON storage_backends(cluster_id, is_default) WHERE cluster_id IS NOT NULL; + +-- Add comment for documentation +COMMENT ON COLUMN storage_backends.cluster_id IS 'Associated cluster ID for cluster-level default storage. NULL means workspace or shared storage.'; \ No newline at end of file diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index f0af7fc..552dd1a 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -27,8 +27,8 @@ export default function UsersManagementPage() { adminApi.listUsers(), workspaceApi.list(), ]); - setUsers(usersRes.data.users || []); - setWorkspaces(workspacesRes.data.workspaces || []); + setUsers(usersRes.data.data?.users || usersRes.data.users || []); + setWorkspaces(workspacesRes.data.data?.workspaces || workspacesRes.data.workspaces || []); } catch (error) { console.error('Failed to fetch data:', error); } finally { diff --git a/frontend/src/app/admin/workspaces/page.tsx b/frontend/src/app/admin/workspaces/page.tsx index 40ea96e..05218e0 100644 --- a/frontend/src/app/admin/workspaces/page.tsx +++ b/frontend/src/app/admin/workspaces/page.tsx @@ -2,13 +2,14 @@ import { useEffect, useState } from 'react'; import { useAuth } from '@/lib/auth-context'; -import { workspaceApi } from '@/lib/api'; -import type { WorkspaceDTO, QuotaDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types'; -import { FolderKanban, Plus, Trash2, Edit, Settings } from 'lucide-react'; +import { workspaceApi, clusterApi } from '@/lib/api'; +import type { WorkspaceDTO, QuotaDTO, ClusterDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types'; +import { FolderKanban, Plus, Trash2, Edit, Settings, Server } from 'lucide-react'; export default function WorkspacesPage() { const { user } = useAuth(); const [workspaces, setWorkspaces] = useState([]); + const [clusters, setClusters] = useState([]); const [quotas, setQuotas] = useState>({}); const [isLoading, setIsLoading] = useState(true); const [showForm, setShowForm] = useState(false); @@ -18,6 +19,10 @@ export default function WorkspacesPage() { const [formData, setFormData] = useState({ name: '', description: '', + cluster_ids: [], + cpu: { hard_limit: 10, soft_limit: 8 }, + gpu: { hard_limit: 2, soft_limit: 1 }, + gpu_memory: { hard_limit: 16, soft_limit: 8 }, }); const [quotaFormData, setQuotaFormData] = useState({ cpu: { hard_limit: 10, soft_limit: 8 }, @@ -28,7 +33,7 @@ export default function WorkspacesPage() { const fetchWorkspaces = async () => { try { const response = await workspaceApi.list(); - setWorkspaces(response.data.workspaces || []); + setWorkspaces(response.data.data?.workspaces || response.data.workspaces || []); } catch (error) { console.error('Failed to fetch workspaces:', error); } finally { @@ -36,6 +41,16 @@ export default function WorkspacesPage() { } }; + const fetchClusters = async () => { + try { + const response = await clusterApi.list(); + const clusterList = response.data.clusters || response.data || []; + setClusters(Array.isArray(clusterList) ? clusterList : []); + } catch (error) { + console.error('Failed to fetch clusters:', error); + } + }; + const fetchQuotas = async (workspaceId: string) => { try { const response = await workspaceApi.getQuotas(workspaceId); @@ -47,6 +62,7 @@ export default function WorkspacesPage() { useEffect(() => { fetchWorkspaces(); + fetchClusters(); }, []); useEffect(() => { @@ -60,14 +76,23 @@ export default function WorkspacesPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { + const request = { + name: formData.name, + description: formData.description, + cluster_ids: formData.cluster_ids, + cpu: formData.cpu, + gpu: formData.gpu, + gpu_memory: formData.gpu_memory, + }; + if (editingWorkspace) { - await workspaceApi.update(editingWorkspace.id, formData); + await workspaceApi.update(editingWorkspace.id, { name: formData.name, description: formData.description, cluster_ids: formData.cluster_ids }); } else { - await workspaceApi.create(formData); + await workspaceApi.create(request); } setShowForm(false); setEditingWorkspace(null); - setFormData({ name: '', description: '' }); + setFormData({ name: '', description: '', cluster_ids: [], cpu: { hard_limit: 10, soft_limit: 8 }, gpu: { hard_limit: 2, soft_limit: 1 }, gpu_memory: { hard_limit: 16, soft_limit: 8 } }); fetchWorkspaces(); } catch (error) { console.error('Failed to save workspace:', error); @@ -77,7 +102,14 @@ export default function WorkspacesPage() { const handleEdit = (workspace: WorkspaceDTO) => { setEditingWorkspace(workspace); - setFormData({ name: workspace.name, description: workspace.description || '' }); + setFormData({ + name: workspace.name, + description: workspace.description || '', + cluster_ids: workspace.cluster_ids || [], + cpu: { hard_limit: 10, soft_limit: 8 }, + gpu: { hard_limit: 2, soft_limit: 1 }, + gpu_memory: { hard_limit: 16, soft_limit: 8 }, + }); setShowForm(true); }; @@ -104,6 +136,16 @@ export default function WorkspacesPage() { } }; + const handleClusterToggle = (clusterId: string) => { + setFormData(prev => { + const current = prev.cluster_ids || []; + const updated = current.includes(clusterId) + ? current.filter(id => id !== clusterId) + : [...current, clusterId]; + return { ...prev, cluster_ids: updated }; + }); + }; + if (user?.role !== 'admin') { return (
@@ -126,13 +168,13 @@ export default function WorkspacesPage() {

Workspaces

-

Manage workspaces and quotas

+

Manage workspaces, clusters and quotas

+ {/* Assigned Clusters */} + {workspace.cluster_ids && workspace.cluster_ids.length > 0 && ( +
+

Assigned Clusters

+
+ {workspace.cluster_ids.map(clusterId => { + const cluster = clusters.find(c => c.id === clusterId); + return ( + + {cluster?.name || clusterId.substring(0, 8)} + + ); + })} +
+
+ )} + {/* Quotas Display */} {quotas[workspace.id] && quotas[workspace.id].length > 0 && (
diff --git a/frontend/src/app/chart-references/page.tsx b/frontend/src/app/chart-references/page.tsx index 8899c65..fd7da80 100644 --- a/frontend/src/app/chart-references/page.tsx +++ b/frontend/src/app/chart-references/page.tsx @@ -23,7 +23,12 @@ export default function ChartReferencesPage() { const fetchChartRefs = async () => { try { const response = await chartReferenceApi.list(); - setChartRefs(response.data || []); + const data = response.data; + if (Array.isArray(data)) { + setChartRefs(data); + } else { + setChartRefs(data?.data || data?.chart_references || data?.chartReferences || []); + } } catch (error) { console.error('Failed to fetch chart references:', error); } finally { @@ -34,7 +39,12 @@ export default function ChartReferencesPage() { const fetchRegistries = async () => { try { const response = await registryApi.list(); - setRegistries(response.data.registries || []); + const data = response.data; + if (Array.isArray(data)) { + setRegistries(data); + } else { + setRegistries(data?.registries || data?.data?.registries || []); + } } catch (error) { console.error('Failed to fetch registries:', error); } diff --git a/frontend/src/app/charts/page.tsx b/frontend/src/app/charts/page.tsx index fff53a1..098eb91 100644 --- a/frontend/src/app/charts/page.tsx +++ b/frontend/src/app/charts/page.tsx @@ -19,12 +19,12 @@ interface RegistryDTO { interface CreateInstanceRequest { name: string; namespace: string; + registryId: string; repository: string; chart: string; version: string; description?: string; - values_yaml?: string; - registry_id?: string; + valuesYaml?: string; } interface ClusterDTO { @@ -68,6 +68,7 @@ export default function ChartsPage() { const [selectedArtifact, setSelectedArtifact] = useState(null); const [isDeploying, setIsDeploying] = useState(false); const [deployError, setDeployError] = useState(null); + const [valuesYamlError, setValuesYamlError] = useState(null); const [deployForm, setDeployForm] = useState({ name: '', namespace: 'default', @@ -136,21 +137,30 @@ export default function ChartsPage() { const handleStorageChange = (storageId: string) => { const storage = storages.find(s => s.id === storageId); if (storage) { - setDeployForm(prev => ({ ...prev, selectedStorageId: storageId })); - // Merge storage config into values (simple merge for NFS) - try { - const storageConfig = `persistence: + // Merge storage config into values using proper persistence format + let storageConfig = ''; + if (storage.type === 'nfs') { + // For NFS, use the storage name as storageClass reference + storageConfig = `persistence: + enabled: true + storageClass: "${storage.name}" + existingClaim: "" + mountOptions: + - hard + - nfsvers=4.1 +`; + } else { + storageConfig = `persistence: enabled: true storageClass: "${storage.type}" + existingClaim: "" `; - setDeployForm(prev => ({ - ...prev, - selectedStorageId: storageId, - valuesYaml: prev.valuesYaml + '\n' + storageConfig - })); - } catch (e) { - console.error('Failed to merge storage config:', e); } + setDeployForm(prev => ({ + ...prev, + selectedStorageId: storageId, + valuesYaml: prev.valuesYaml ? prev.valuesYaml + '\n' + storageConfig : storageConfig + })); } }; @@ -176,6 +186,17 @@ export default function ChartsPage() { // Filter to only show chart type artifacts const allArtifacts = Array.isArray(response.data) ? response.data : []; const chartArtifacts = allArtifacts.filter((a: Artifact) => a.type === 'chart'); + // Sort by semver descending so index 0 = latest (most recent version) + chartArtifacts.sort((a, b) => { + const pa = a.tag.split('.').map(Number); + const pb = b.tag.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const na = pa[i] || 0; + const nb = pb[i] || 0; + if (na !== nb) return nb - na; + } + return 0; + }); setArtifacts(chartArtifacts); } catch (error) { console.error('Failed to fetch artifacts:', error); @@ -226,9 +247,9 @@ export default function ChartsPage() { repository: selectedRepo!, chart: selectedRepo!.split('/').pop() || selectedRepo!, version: selectedArtifact.tag, - registry_id: selectedRegistry?.id, + registryId: selectedRegistry?.id || '', description: deployForm.description, - values_yaml: deployForm.valuesYaml || undefined, + valuesYaml: deployForm.valuesYaml || undefined, }; await instanceApi.create(deployForm.clusterId, request); @@ -260,7 +281,7 @@ export default function ChartsPage() { const openDeployModal = (artifact: Artifact) => { setSelectedArtifact(artifact); setDeployForm({ - name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(), + name: artifact.repositoryName.split('/').pop()!.toLowerCase(), namespace: 'default', clusterId: clusters[0]?.id || '', description: '', @@ -459,7 +480,7 @@ export default function ChartsPage() { {/* Deploy Modal */} {showDeployModal && selectedArtifact && (
-
+

Deploy Chart