feat: complete E2E deployment flow with storage layered config and values template versioning

- Instance deployment: charts browser, deploy modal, instances list
- Values Template version management (create/history/rollback)
- Storage layered config (cluster > workspace > shared priority)
- Cluster credential decryptIfNeeded for mixed encrypted/plaintext kubeconfig
- YAML syntax validation (client-side + server-side warning)
- Frontend: charts, instances, storage, templates, admin pages
- Backend: storage service, instance service, cluster service, helm client
- Multi-Tenant Kubeconfig.md: added by user
This commit is contained in:
Ivan087
2026-04-30 16:31:00 +08:00
parent 985369d40f
commit 47849042a7
42 changed files with 2029 additions and 255 deletions

17
.gitignore vendored
View File

@ -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/

127
Multi-Tenant Kubeconfig.md Normal file
View File

@ -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://<k8s-api-server>
certificate-authority-data: <ca-base64>
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.

View File

@ -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)

View File

@ -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:

View File

@ -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,

View File

@ -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 存储配置(脱敏后)

View File

@ -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

View File

@ -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)
}

View File

@ -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"),
})

View File

@ -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),

View File

@ -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(), "")

View File

@ -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

View File

@ -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
}

View File

@ -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 列出所有存储(管理员用)

View File

@ -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)

View File

@ -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)
}

View File

@ -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_<NAME>_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)
}

View File

@ -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()
}

View File

@ -43,6 +43,7 @@ type HostPathConfig struct {
type StorageBackend struct {
ID string
WorkspaceID string
ClusterID string // 关联的 clusterNULL 表示 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 == "" {

View File

@ -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,

View File

@ -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

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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);

View File

@ -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.';

View File

@ -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 {

View File

@ -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<WorkspaceDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
@ -18,6 +19,10 @@ export default function WorkspacesPage() {
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
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<SetQuotasRequest>({
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 (
<div className="flex items-center justify-center h-64">
@ -126,13 +168,13 @@ export default function WorkspacesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Workspaces</h1>
<p className="text-[var(--muted-foreground)]">Manage workspaces and quotas</p>
<p className="text-[var(--muted-foreground)]">Manage workspaces, clusters and quotas</p>
</div>
<button
onClick={() => {
setShowForm(true);
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 } });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
@ -143,8 +185,8 @@ export default function WorkspacesPage() {
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-md border border-[var(--border)]">
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 overflow-y-auto py-8">
<div className="bg-[var(--card)] rounded-lg p-6 w-full max-w-lg border border-[var(--border)] m-auto">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
</h2>
@ -165,9 +207,80 @@ export default function WorkspacesPage() {
value={formData.description || ''}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input"
rows={3}
rows={2}
/>
</div>
{/* Cluster Selection */}
{!editingWorkspace && (
<>
<div>
<label className="label flex items-center gap-2">
<Server className="w-4 h-4" />
Assign Clusters
</label>
{clusters.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">No clusters available</p>
) : (
<div className="space-y-2 max-h-40 overflow-y-auto">
{clusters.map((cluster) => (
<label key={cluster.id} className="flex items-center gap-3 p-2 rounded-lg bg-[var(--secondary)] cursor-pointer hover:bg-[var(--secondary)]/80">
<input
type="checkbox"
checked={formData.cluster_ids?.includes(cluster.id) || false}
onChange={() => handleClusterToggle(cluster.id)}
className="w-4 h-4 rounded border-[var(--border)]"
/>
<div className="flex-1">
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-xs text-[var(--muted-foreground)]">{cluster.host}</p>
</div>
</label>
))}
</div>
)}
</div>
{/* Resource Quotas */}
<div className="border-t border-[var(--border)] pt-4">
<label className="label mb-3">Resource Quotas (Optional)</label>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-[var(--muted-foreground)]">CPU (cores)</label>
<input
type="number"
value={formData.cpu?.hard_limit ?? 10}
onChange={(e) => setFormData({ ...formData, cpu: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.cpu?.soft_limit ?? 8 } })}
className="input"
min="0"
placeholder="Hard limit"
/>
</div>
<div>
<label className="text-xs text-[var(--muted-foreground)]">GPU (cards)</label>
<input
type="number"
value={formData.gpu?.hard_limit ?? 2}
onChange={(e) => setFormData({ ...formData, gpu: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.gpu?.soft_limit ?? 1 } })}
className="input"
min="0"
/>
</div>
<div>
<label className="text-xs text-[var(--muted-foreground)]">GPU Memory (GB)</label>
<input
type="number"
value={formData.gpu_memory?.hard_limit ?? 16}
onChange={(e) => setFormData({ ...formData, gpu_memory: { hard_limit: parseFloat(e.target.value) || 0, soft_limit: formData.gpu_memory?.soft_limit ?? 8 } })}
className="input"
min="0"
/>
</div>
</div>
</div>
</>
)}
<div className="flex gap-3 pt-4">
<button
type="button"
@ -376,6 +489,23 @@ export default function WorkspacesPage() {
</div>
</div>
{/* Assigned Clusters */}
{workspace.cluster_ids && workspace.cluster_ids.length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">
<p className="text-sm font-medium text-[var(--foreground)] mb-2">Assigned Clusters</p>
<div className="flex flex-wrap gap-2">
{workspace.cluster_ids.map(clusterId => {
const cluster = clusters.find(c => c.id === clusterId);
return (
<span key={clusterId} className="px-2 py-1 bg-[var(--secondary)] rounded text-xs text-[var(--foreground)]">
{cluster?.name || clusterId.substring(0, 8)}
</span>
);
})}
</div>
</div>
)}
{/* Quotas Display */}
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
<div className="mt-4 pt-4 border-t border-[var(--border)]">

View File

@ -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);
}

View File

@ -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<Artifact | null>(null);
const [isDeploying, setIsDeploying] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [valuesYamlError, setValuesYamlError] = useState<string | null>(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 && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)]">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-lg border border-[var(--border)] max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
<button
@ -586,13 +607,42 @@ export default function ChartsPage() {
</label>
<textarea
value={deployForm.valuesYaml}
onChange={(e) => setDeployForm({ ...deployForm, valuesYaml: e.target.value })}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
onChange={(e) => {
const val = e.target.value;
setDeployForm({ ...deployForm, valuesYaml: val });
// Client-side YAML syntax validation
if (val.trim()) {
try {
// Quick line-by-line check for common mistakes
const lines = val.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed && !trimmed.startsWith('#') && trimmed.includes('=') && !trimmed.includes(':')) {
const key = trimmed.split('=')[0].trim();
if (key && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
setValuesYamlError(`Syntax error: use ":" instead of "=" for key "${key}" (YAML uses colons, not equals signs)`);
return;
}
}
}
setValuesYamlError(null);
} catch {}
} else {
setValuesYamlError(null);
}
}}
className={`w-full px-3 py-2 bg-[var(--background)] border rounded-lg text-[var(--foreground)] font-mono text-sm ${valuesYamlError ? 'border-red-500' : 'border-[var(--border)]'}`}
rows={4}
placeholder="# Optional: Override chart values&#10;replicaCount: 2&#10;image:&#11; tag: latest"
/>
</div>
{valuesYamlError && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
{valuesYamlError}
</div>
)}
{deployError && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
{deployError}
@ -613,7 +663,7 @@ export default function ChartsPage() {
<button
type="button"
onClick={handleDeploy}
disabled={isDeploying || !deployForm.name || !deployForm.clusterId}
disabled={isDeploying || !deployForm.name || !deployForm.clusterId || !!valuesYamlError}
className="flex-1 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 flex items-center justify-center gap-2"
>
{isDeploying && <Loader2 className="w-4 h-4 animate-spin" />}

View File

@ -0,0 +1,250 @@
'use client';
import { useEffect, useState } from 'react';
import { instanceApi, clusterApi } from '@/lib/api';
import { Rocket, RefreshCw, Trash2, ChevronRight, Loader2, AlertCircle, CheckCircle, Clock, XCircle } from 'lucide-react';
import Link from 'next/link';
interface InstanceDTO {
id: string;
clusterId: string;
clusterName?: string;
registryId?: string;
name: string;
namespace: string;
repository: string;
chart: string;
version: string;
description?: string;
status: string;
statusReason?: string;
lastError?: string;
revision: number;
createdAt: string;
updatedAt: string;
}
interface ClusterDTO {
id: string;
name: string;
host: string;
}
const statusConfig: Record<string, { icon: React.ReactNode; color: string; bg: string }> = {
pending: { icon: <Clock className="w-4 h-4" />, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
deployed: { icon: <CheckCircle className="w-4 h-4" />, color: 'text-green-500', bg: 'bg-green-500/10' },
failed: { icon: <XCircle className="w-4 h-4" />, color: 'text-red-500', bg: 'bg-red-500/10' },
deploying: { icon: <Loader2 className="w-4 h-4 animate-spin" />, color: 'text-blue-500', bg: 'bg-blue-500/10' },
};
export default function InstancesPage() {
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [instances, setInstances] = useState<InstanceDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingInstances, setIsLoadingInstances] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{ clusterId: string; instanceId: string } | null>(null);
useEffect(() => {
fetchClusters();
}, []);
useEffect(() => {
if (selectedClusterId) {
fetchInstances(selectedClusterId);
}
}, [selectedClusterId]);
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
const clusterList: ClusterDTO[] = Array.isArray(data) ? data : (data?.clusters || []);
setClusters(clusterList);
if (clusterList.length > 0 && !selectedClusterId) {
setSelectedClusterId(clusterList[0].id);
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
} finally {
setIsLoading(false);
}
};
const fetchInstances = async (clusterId: string) => {
setIsLoadingInstances(true);
try {
const response = await instanceApi.list(clusterId);
const data = response.data;
const instanceList: InstanceDTO[] = Array.isArray(data) ? data : (data?.instances || []);
setInstances(instanceList);
} catch (error) {
console.error('Failed to fetch instances:', error);
setInstances([]);
} finally {
setIsLoadingInstances(false);
}
};
const handleDelete = async (clusterId: string, instanceId: string) => {
try {
await instanceApi.delete(clusterId, instanceId);
fetchInstances(clusterId);
setDeleteConfirm(null);
} catch (error) {
console.error('Failed to delete instance:', error);
alert('Failed to delete instance');
}
};
const getStatusStyle = (status: string) => {
const normalized = status?.toLowerCase() || 'pending';
return statusConfig[normalized] || statusConfig.pending;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--foreground)]">Deployments</h1>
<p className="text-[var(--muted-foreground)]">View and manage your Helm chart deployments</p>
</div>
<button
onClick={() => selectedClusterId && fetchInstances(selectedClusterId)}
className="flex items-center gap-2 px-4 py-2 border border-[var(--border)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* Cluster Selector */}
<div className="flex items-center gap-4">
<label className="text-sm font-medium text-[var(--foreground)]">Cluster:</label>
<select
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
className="px-3 py-2 bg-[var(--card)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">Select a cluster</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
<Link
href="/charts"
className="flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Rocket className="w-4 h-4" />
New Deployment
</Link>
</div>
{/* Instances List */}
{isLoadingInstances ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-[var(--primary)]" />
</div>
) : instances.length === 0 ? (
<div className="card text-center py-12">
<Rocket className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<h3 className="text-lg font-medium text-[var(--foreground)] mb-2">No Deployments Yet</h3>
<p className="text-[var(--muted-foreground)] mb-4">
You haven&apos;t deployed any Helm charts to this cluster yet.
</p>
<Link
href="/charts"
className="inline-flex items-center gap-2 px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-lg hover:opacity-90 transition-opacity"
>
<Rocket className="w-4 h-4" />
Deploy Your First Chart
</Link>
</div>
) : (
<div className="space-y-3">
{instances.map((instance) => {
const statusStyle = getStatusStyle(instance.status);
return (
<div key={instance.id} className="card p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`p-2 rounded-lg ${statusStyle.bg}`}>
<span className={statusStyle.color}>{statusStyle.icon}</span>
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-[var(--foreground)]">{instance.name}</h3>
<span className="badge">{instance.status}</span>
</div>
<div className="flex items-center gap-3 text-sm text-[var(--muted-foreground)] mt-1">
<span>{instance.chart}:{instance.version}</span>
<span>Namespace: {instance.namespace}</span>
<span>Revision: {instance.revision}</span>
</div>
{instance.lastError && (
<div className="flex items-center gap-2 text-sm text-red-500 mt-1">
<AlertCircle className="w-3 h-3" />
{instance.lastError}
</div>
)}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm text-[var(--muted-foreground)]">
Updated {new Date(instance.updatedAt).toLocaleString()}
</div>
<button
onClick={() => setDeleteConfirm({ clusterId: instance.clusterId, instanceId: instance.id })}
className="p-2 hover:bg-red-500/10 rounded-lg text-red-500 transition-colors"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
<ChevronRight className="w-5 h-5 text-[var(--muted-foreground)]" />
</div>
</div>
</div>
);
})}
</div>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--card)] rounded-xl p-6 w-full max-w-md border border-[var(--border)]">
<h3 className="text-xl font-bold text-[var(--foreground)] mb-4">Delete Deployment</h3>
<p className="text-[var(--muted-foreground)] mb-6">
Are you sure you want to delete this deployment? This action cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={() => setDeleteConfirm(null)}
className="flex-1 px-4 py-2 border border-[var(--border)] text-[var(--foreground)] rounded-lg hover:bg-[var(--secondary)] transition-colors"
>
Cancel
</button>
<button
onClick={() => handleDelete(deleteConfirm.clusterId, deleteConfirm.instanceId)}
className="flex-1 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -3,14 +3,13 @@
import { useState, FormEvent, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/auth-context';
import { Shield, Loader2, CheckCircle } from 'lucide-react';
import { Shield, Loader2 } from 'lucide-react';
export default function LoginPage() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [loginSuccess, setLoginSuccess] = useState(false);
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
const router = useRouter();
@ -37,20 +36,16 @@ export default function LoginPage() {
try {
await login({ username, password });
setLoginSuccess(true);
// Small delay to show success state, then redirect
setTimeout(() => {
router.push('/');
}, 500);
// Redirect immediately after successful login
window.location.href = '/';
} catch (err: unknown) {
setIsLoading(false);
if (typeof err === 'object' && err !== null && 'response' in err) {
const axiosErr = err as { response?: { data?: { message?: string } } };
setError(axiosErr.response?.data?.message || 'Login failed');
} else {
setError('Login failed. Please check your credentials.');
}
} finally {
setIsLoading(false);
}
};
@ -66,13 +61,6 @@ export default function LoginPage() {
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{loginSuccess && (
<div className="p-3 rounded-md bg-[rgba(34,197,94,0.1)] border border-[rgba(34,197,94,0.3)] flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-500" />
<p className="text-sm text-green-500">Login successful! Redirecting...</p>
</div>
)}
{error && (
<div className="p-3 rounded-md bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.3)]">
<p className="text-sm text-[#ef4444]">{error}</p>

View File

@ -2,24 +2,36 @@
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useAuth } from '@/lib/auth-context';
import { monitoringApi, clusterApi } from '@/lib/api';
import { Activity, Server, Container } from 'lucide-react';
import { Activity, Server, Container, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
interface DashboardStats {
totalClusters: number;
healthyClusters: number;
warningClusters: number;
errorClusters: number;
totalInstances: number;
runningInstances: number;
totalNodes: number;
}
interface ClusterStatus {
id: string;
name: string;
host: string;
status: string;
message?: string;
}
export default function DashboardPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [clusters, setClusters] = useState<Array<{ id: string; name: string; host: string }>>([]);
const [clusters, setClusters] = useState<ClusterStatus[]>([]);
const [isLoadingData, setIsLoadingData] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
// Redirect to login if not authenticated
useEffect(() => {
@ -28,8 +40,31 @@ export default function DashboardPage() {
}
}, [isLoading, isAuthenticated, router]);
const fetchClusterHealth = async (clusterId: string, clusterName: string, host: string): Promise<ClusterStatus> => {
try {
const res = await clusterApi.getHealth(clusterId);
const healthy = res.data?.healthy ?? false;
return {
id: clusterId,
name: clusterName,
host,
status: healthy ? 'healthy' : 'error',
message: res.data?.message,
};
} catch (error) {
return {
id: clusterId,
name: clusterName,
host,
status: 'error',
message: 'Failed to connect',
};
}
};
useEffect(() => {
const fetchData = async () => {
setFetchError(null);
try {
const [summaryRes, clustersRes] = await Promise.all([
monitoringApi.getSummary().catch(() => null),
@ -40,21 +75,35 @@ export default function DashboardPage() {
setStats({
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
healthyClusters: summaryRes.data.healthyClusters ?? summaryRes.data.healthy_clusters ?? 0,
warningClusters: summaryRes.data.warningClusters ?? summaryRes.data.warning_clusters ?? 0,
errorClusters: summaryRes.data.errorClusters ?? summaryRes.data.error_clusters ?? 0,
totalInstances: summaryRes.data.totalInstances ?? summaryRes.data.total_instances ?? 0,
runningInstances: summaryRes.data.runningInstances ?? summaryRes.data.running_instances ?? 0,
totalNodes: summaryRes.data.totalNodes ?? summaryRes.data.total_nodes ?? 0,
});
}
// Handle both {clusters: []} and array response
// Handle cluster list and fetch health for each
const clustersData = clustersRes?.data;
let clusterList: Array<{ id: string; name: string; host: string }> = [];
if (Array.isArray(clustersData)) {
setClusters(clustersData);
clusterList = clustersData;
} else if (clustersData?.clusters) {
setClusters(clustersData.clusters);
clusterList = clustersData.clusters;
}
if (clusterList.length > 0) {
// Fetch health for each cluster concurrently
const healthResults = await Promise.all(
clusterList.map(c => fetchClusterHealth(c.id, c.name, c.host))
);
setClusters(healthResults);
} else {
setClusters([]);
}
} catch (error) {
console.error('Failed to fetch dashboard data:', error);
setFetchError('Failed to load dashboard data. Please check your connection.');
} finally {
setIsLoadingData(false);
}
@ -65,6 +114,24 @@ export default function DashboardPage() {
}
}, [isLoading]);
const getStatusIcon = (status: ClusterStatus['status']) => {
switch (status) {
case 'healthy': return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'warning': return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'error': return <XCircle className="w-4 h-4 text-red-500" />;
default: return <Server className="w-4 h-4 text-gray-500" />;
}
};
const getStatusBadge = (status: ClusterStatus['status']) => {
switch (status) {
case 'healthy': return <span className="badge badge-success">Healthy</span>;
case 'warning': return <span className="badge bg-yellow-500/10 text-yellow-500">Warning</span>;
case 'error': return <span className="badge bg-red-500/10 text-red-500">Error</span>;
default: return <span className="badge bg-gray-500/10 text-gray-500">Unknown</span>;
}
};
if (isLoading || isLoadingData) {
return (
<div className="flex items-center justify-center h-64">
@ -83,8 +150,18 @@ export default function DashboardPage() {
</p>
</div>
{/* Error Banner */}
{fetchError && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-500">
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5" />
<span>{fetchError}</span>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
title="Total Clusters"
value={stats?.totalClusters ?? 0}
@ -92,30 +169,47 @@ export default function DashboardPage() {
color="blue"
/>
<StatCard
title="Healthy Clusters"
title="Healthy"
value={stats?.healthyClusters ?? 0}
icon={Activity}
icon={CheckCircle}
color="green"
/>
<StatCard
title="Total Instances"
value={stats?.totalInstances ?? 0}
title="Warning"
value={stats?.warningClusters ?? 0}
icon={AlertTriangle}
color="yellow"
/>
<StatCard
title="Error"
value={stats?.errorClusters ?? 0}
icon={XCircle}
color="red"
/>
<StatCard
title="Nodes"
value={stats?.totalNodes ?? 0}
icon={Container}
color="purple"
/>
<StatCard
title="Running Instances"
value={stats?.runningInstances ?? 0}
icon={Activity}
color="green"
/>
</div>
{/* Clusters List */}
<div className="card">
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">Clusters</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-[var(--foreground)]">Clusters</h2>
<Link href="/clusters" className="text-sm text-[var(--primary)] hover:underline">
Manage Clusters
</Link>
</div>
{clusters.length === 0 ? (
<p className="text-[var(--muted-foreground)]">No clusters configured</p>
<div className="text-center py-8">
<Server className="w-12 h-12 mx-auto text-[var(--muted-foreground)] mb-4" />
<p className="text-[var(--muted-foreground)] mb-4">No clusters configured</p>
<Link href="/clusters" className="text-[var(--primary)] hover:underline">
Add your first cluster
</Link>
</div>
) : (
<div className="space-y-2">
{clusters.map((cluster) => (
@ -124,13 +218,16 @@ export default function DashboardPage() {
className="flex items-center justify-between p-3 rounded-lg bg-[var(--secondary)]"
>
<div className="flex items-center gap-3">
<Server className="w-5 h-5 text-[var(--primary)]" />
{getStatusIcon(cluster.status)}
<div>
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</p>
{cluster.message && cluster.status !== 'healthy' && (
<p className="text-xs text-red-500 mt-1">{cluster.message}</p>
)}
</div>
</div>
<div className="badge badge-success">Active</div>
{getStatusBadge(cluster.status)}
</div>
))}
</div>
@ -149,13 +246,15 @@ function StatCard({
title: string;
value: number;
icon: React.ElementType;
color: 'blue' | 'green' | 'purple' | 'orange';
color: 'blue' | 'green' | 'purple' | 'orange' | 'yellow' | 'red';
}) {
const colorClasses = {
blue: 'text-blue-500 bg-blue-500/10',
green: 'text-green-500 bg-green-500/10',
purple: 'text-purple-500 bg-purple-500/10',
orange: 'text-orange-500 bg-orange-500/10',
yellow: 'text-yellow-500 bg-yellow-500/10',
red: 'text-red-500 bg-red-500/10',
};
return (

View File

@ -1,28 +1,40 @@
'use client';
import { useEffect, useState } from 'react';
import { storageApi } from '@/lib/api';
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest } from '@/lib/types';
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2 } from 'lucide-react';
import { storageApi, clusterApi } from '@/lib/api';
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest, ClusterDTO, StorageResolutionDTO } from '@/lib/types';
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-react';
export default function StoragePage() {
const [storages, setStorages] = useState<StorageDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [activeTab, setActiveTab] = useState<'list' | 'layered'>('list');
const [showLayeredConfig, setShowLayeredConfig] = useState(false);
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [resolvedStorage, setResolvedStorage] = useState<StorageResolutionDTO | null>(null);
const [isResolving, setIsResolving] = useState(false);
const [formData, setFormData] = useState<CreateStorageRequest>({
name: '',
type: 'nfs',
description: '',
is_default: false,
is_shared: false,
cluster_id: '',
});
const fetchStorages = async () => {
try {
const response = await storageApi.list();
setStorages(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setStorages(data);
} else {
setStorages(data?.storages || data?.data?.storages || []);
}
} catch (error) {
console.error('Failed to fetch storages:', error);
} finally {
@ -30,10 +42,50 @@ export default function StoragePage() {
}
};
const fetchClusters = async () => {
try {
const response = await clusterApi.list();
const data = response.data;
if (Array.isArray(data)) {
setClusters(data);
} else if (data && data.clusters) {
setClusters(data.clusters);
}
} catch (error) {
console.error('Failed to fetch clusters:', error);
}
};
const resolveStorage = async (clusterId: string) => {
if (!clusterId) {
setResolvedStorage(null);
return;
}
setIsResolving(true);
try {
const response = await storageApi.resolve(clusterId);
setResolvedStorage(response.data);
} catch (error) {
console.error('Failed to resolve storage:', error);
setResolvedStorage(null);
} finally {
setIsResolving(false);
}
};
useEffect(() => {
fetchStorages();
fetchClusters();
}, []);
useEffect(() => {
if (selectedClusterId) {
resolveStorage(selectedClusterId);
} else {
setResolvedStorage(null);
}
}, [selectedClusterId]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
@ -45,7 +97,7 @@ export default function StoragePage() {
}
setShowForm(false);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false, cluster_id: '' });
fetchStorages();
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
} catch (error) {
@ -64,6 +116,7 @@ export default function StoragePage() {
description: storage.description || '',
is_default: storage.is_default,
is_shared: storage.is_shared,
cluster_id: (storage as any).workspace_id ? '' : (storage as any).cluster_id || '',
nfs: storage.config.nfs,
pv: storage.config.pv,
hostPath: storage.config.hostPath,
@ -157,7 +210,7 @@ export default function StoragePage() {
onClick={() => {
setShowForm(true);
setEditingStorage(null);
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false });
setFormData({ name: '', type: 'nfs', description: '', is_default: false, is_shared: false, cluster_id: '' });
}}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] font-medium hover:opacity-90"
>
@ -166,6 +219,152 @@ export default function StoragePage() {
</button>
</div>
{/* Tab Switcher */}
<div className="flex gap-2 border-b border-[var(--border)]">
<button
onClick={() => setActiveTab('list')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'list'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
<HardDrive className="w-4 h-4 inline mr-2" />
Storage Backends
</button>
<button
onClick={() => setActiveTab('layered')}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === 'layered'
? 'border-[var(--primary)] text-[var(--primary)]'
: 'border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]'
}`}
>
<Layers className="w-4 h-4 inline mr-2" />
Layered Config
</button>
</div>
{/* Layered Config Tab */}
{activeTab === 'layered' && (
<div className="card">
<div className="mb-4">
<h3 className="text-lg font-semibold text-[var(--foreground)] mb-2">Storage Resolution Preview</h3>
<p className="text-sm text-[var(--muted-foreground)] mb-4">
Priority order: <span className="font-medium text-[var(--foreground)]">Workspace Default</span> &gt; <span className="font-medium text-[var(--foreground)]">Cluster Default</span> &gt; <span className="font-medium text-[var(--foreground)]">Shared Default</span>
</p>
<div className="flex items-center gap-4">
<select
value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)}
className="input max-w-sm"
>
<option value="">Select a cluster...</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
{selectedClusterId && (
<button
onClick={() => resolveStorage(selectedClusterId)}
className="px-3 py-2 rounded-lg bg-[var(--primary)] text-[var(--primary-foreground)] text-sm font-medium hover:opacity-90 flex items-center gap-1"
>
{isResolving && <Loader2 className="w-3 h-3 animate-spin" />}
Refresh
</button>
)}
</div>
</div>
{/* Priority Legend */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold flex items-center justify-center">1</span>
<span className="text-sm font-medium text-[var(--foreground)]">Workspace Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Highest priority. Per-workspace storage config.</p>
</div>
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-purple-500 text-white text-xs font-bold flex items-center justify-center">2</span>
<span className="text-sm font-medium text-[var(--foreground)]">Cluster Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Medium priority. Cluster-specific storage.</p>
</div>
<div className="p-3 rounded-lg border border-[var(--border)] bg-[var(--secondary)]/50">
<div className="flex items-center gap-2 mb-1">
<span className="w-6 h-6 rounded-full bg-green-500 text-white text-xs font-bold flex items-center justify-center">3</span>
<span className="text-sm font-medium text-[var(--foreground)]">Shared Default</span>
</div>
<p className="text-xs text-[var(--muted-foreground)]">Lowest priority. Shared across workspaces.</p>
</div>
</div>
{/* Resolution Result */}
{isResolving ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-[var(--primary)]" />
<span className="ml-2 text-[var(--muted-foreground)]">Resolving storage config...</span>
</div>
) : resolvedStorage?.storage ? (
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className={`w-3 h-3 rounded-full ${
resolvedStorage.source === 'workspace' ? 'bg-blue-500' :
resolvedStorage.source === 'cluster' ? 'bg-purple-500' : 'bg-green-500'
}`} />
<div>
<p className="text-sm font-medium text-[var(--foreground)]">
Resolved from: <span className="uppercase">{resolvedStorage.source}</span>
</p>
<p className="text-sm text-[var(--muted-foreground)]">{resolvedStorage.storage.name}</p>
</div>
</div>
<div className="p-4 rounded-lg bg-[var(--secondary)] border border-[var(--border)]">
<div className="flex items-center gap-2 mb-2">
{getTypeIcon(resolvedStorage.storage.type)}
<span className="font-medium text-[var(--foreground)]">{resolvedStorage.storage.name}</span>
<span className="badge">{getTypeLabel(resolvedStorage.storage.type)}</span>
{resolvedStorage.storage.is_default && <span className="badge badge-info">Default</span>}
</div>
{renderConfig(resolvedStorage.storage)}
</div>
{resolvedStorage.values_yaml && (
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-[var(--foreground)]">Generated Values YAML</span>
<button
onClick={() => {
navigator.clipboard.writeText(resolvedStorage.values_yaml!);
alert('Copied to clipboard!');
}}
className="text-xs text-[var(--primary)] hover:underline"
>
Copy
</button>
</div>
<pre className="p-3 rounded-lg bg-[var(--secondary)] border border-[var(--border)] text-xs font-mono text-[var(--foreground)] overflow-x-auto whitespace-pre-wrap">
{resolvedStorage.values_yaml}
</pre>
</div>
)}
</div>
) : (
<div className="text-center py-8">
<Layers className="w-10 h-10 mx-auto text-[var(--muted-foreground)] mb-3" />
<p className="text-[var(--muted-foreground)]">
{selectedClusterId
? 'No default storage configured for this workspace/cluster'
: 'Select a cluster to preview the resolved storage configuration'}
</p>
</div>
)}
</div>
)}
{/* Form Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
@ -263,6 +462,22 @@ export default function StoragePage() {
</div>
)}
<div>
<label className="label">Cluster (Optional)</label>
<select
value={formData.cluster_id || ''}
onChange={(e) => setFormData({ ...formData, cluster_id: e.target.value })}
className="input"
>
<option value="">No cluster (workspace/shared level)</option>
{clusters.map((cluster) => (
<option key={cluster.id} value={cluster.id}>
{cluster.name} ({cluster.host})
</option>
))}
</select>
</div>
<div>
<label className="label">Description</label>
<textarea
@ -353,6 +568,9 @@ export default function StoragePage() {
{storage.is_shared && (
<span className="badge badge-success">Shared</span>
)}
{(storage as any).cluster_id && (
<span className="badge">Cluster</span>
)}
</div>
{renderConfig(storage)}
{storage.description && (

View File

@ -27,7 +27,12 @@ export default function TemplatesPage() {
const fetchTemplates = async () => {
try {
const response = await valuesTemplateApi.list();
setTemplates(response.data || []);
const data = response.data;
if (Array.isArray(data)) {
setTemplates(data);
} else {
setTemplates(data?.templates || data?.data?.templates || []);
}
} catch (error) {
console.error('Failed to fetch templates:', error);
} finally {
@ -38,7 +43,12 @@ export default function TemplatesPage() {
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?.chart_references || data?.data?.chart_references || data?.chartReferences || []);
}
} catch (error) {
console.error('Failed to fetch chart references:', error);
}

View File

@ -15,6 +15,7 @@ import {
HardDrive,
Package,
FileText,
Rocket,
} from 'lucide-react';
import { logout } from '@/lib/api';
@ -23,6 +24,7 @@ const navigation = [
{ name: 'Clusters', href: '/clusters', icon: Server },
{ name: 'Registries', href: '/registries', icon: Database },
{ name: 'Charts', href: '/charts', icon: Package },
{ name: 'Deployments', href: '/instances', icon: Rocket },
{ name: 'Storage', href: '/storage', icon: HardDrive },
{ name: 'Chart References', href: '/chart-references', icon: FileText },
{ name: 'Values Templates', href: '/templates', icon: FileText },

View File

@ -266,6 +266,9 @@ export const storageApi = {
delete: (storageId: string) =>
api.delete(`/storage-backends/${storageId}`),
resolve: (clusterId: string, workspaceId?: string) =>
api.get<any>('/storage-backends/resolve', { params: { cluster_id: clusterId, workspace_id: workspaceId } }),
};
// Chart Reference API

View File

@ -41,16 +41,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const login = async (credentials: LoginRequest) => {
const response = await authApi.login(credentials);
// API returns camelCase: accessToken, refreshToken
const access_token = response.data.accessToken;
const refresh_token = response.data.refreshToken;
// API returns wrapped: { message: "", data: { accessToken, refreshToken } }
const access_token = response.data.data?.accessToken || response.data.accessToken;
const refresh_token = response.data.data?.refreshToken || response.data.refreshToken;
if (!access_token) {
throw new Error('Login failed: no access token received');
}
localStorage.setItem('access_token', access_token);
localStorage.setItem('refresh_token', refresh_token);
// Fetch user info - API returns { message: "", data: { user: {...} } }
const userResponse = await authApi.getCurrentUser();
const user = userResponse.data.data.user;
const user = userResponse.data.data?.user || userResponse.data.user;
setUser(user);
localStorage.setItem('user', JSON.stringify(user));
};

View File

@ -51,6 +51,8 @@ export interface UserListResponse {
export interface WorkspaceDTO {
id: string;
name: string;
cluster_ids?: string[];
quotas?: QuotaDTO[];
description?: string;
created_by: string;
created_at: string;
@ -79,11 +81,16 @@ export interface WorkspaceListResponse {
export interface CreateWorkspaceRequest {
name: string;
description?: string;
cluster_ids?: string[];
cpu?: { hard_limit: number; soft_limit: number };
gpu?: { hard_limit: number; soft_limit: number };
gpu_memory?: { hard_limit: number; soft_limit: number };
}
export interface UpdateWorkspaceRequest {
name?: string;
description?: string;
cluster_ids?: string[];
}
export interface SetQuotasRequest {
@ -214,6 +221,7 @@ export interface InstanceDTO {
export interface CreateInstanceRequest {
name: string;
namespace: string;
registryId: string;
repository: string;
chart: string;
version: string;
@ -323,6 +331,7 @@ export interface CreateStorageRequest {
description?: string;
is_default?: boolean;
is_shared?: boolean;
cluster_id?: string;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
@ -334,11 +343,19 @@ export interface UpdateStorageRequest {
description?: string;
is_default?: boolean;
is_shared?: boolean;
cluster_id?: string;
nfs?: NFSConfig;
pv?: PVConfig;
hostPath?: HostPathConfig;
}
export interface StorageResolutionDTO {
storage?: StorageDTO;
values_yaml?: string;
source?: string; // workspace, cluster, shared
message?: string;
}
// Chart Reference Types
export interface ChartReferenceDTO {
id: string;

View File

@ -24,4 +24,124 @@
**根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步
**影响**: GetByID/GetByName 查询时字段数不匹配会报错
**Fix**: 修复 GetByID/GetByName 的查询和 Scan使用实际的 DB schema
**How to apply**: InitSchema() 和实际 DB schema 必须保持同步
**How to apply**: InitSchema() 和实际 DB schema 必须保持同步
## Bug 5: Frontend CreateInstanceRequest Missing registryId Field (2026-04-17)
**现象**: Charts 页面 Deploy Modal 填好信息后点击 Deploy后端报错无法找到 registry
**根因**: Frontend `CreateInstanceRequest` type 缺少 `registryId` 字段,`charts/page.tsx` 发送 `registry_id`,但后端 DTO 用 `registryId` 接收
**修复**:
1.`frontend/src/lib/types.ts``CreateInstanceRequest` 添加 `registryId: string`
2.`frontend/src/app/charts/page.tsx` 发送 `registryId: selectedRegistry?.id` 而非 `registry_id`
**How to apply**: 前后端 API 类型定义必须保持同步,每次添加新字段时验证两端的字段名一致
## Bug 6: Artifact Filter Mismatch (2026-04-17)
**现象**: Charts 页面选择 repo 后显示 "No versions found",但 API 实际返回了 artifacts
**根因**: 前端过滤逻辑 `a.mediaType?.includes('chart')` 无法匹配 API 返回的 `mediaType: "application/vnd.oci.image.manifest.v1+json"`
**影响**: 用户看不到任何 helm chart 版本
**修复**: 恢复过滤逻辑为 `a.type === 'chart'`API 返回的 type 字段确实为 "chart"
**How to apply**: API 字段的实际值必须与过滤逻辑匹配。type 字段比 mediaType 更可靠
## Bug 7: Docker Node.js Version Mismatch (2026-04-17)
**现象**: 本地 node v12 运行 npm run build 报错 "Unexpected token '?'"
**根因**: Next.js 需要 Node.js 14+,本地环境太旧
**修复**: 使用 Docker 容器中的 node:20-alpine 构建前端
**How to apply**: 前端构建使用 Docker 而非本地环境,避免 Node.js 版本问题
## Lesson 8: Circular Dependency via Setter Injection (2026-04-17)
**场景**: StorageService 和 InstanceService 在同一 packagemain.go 中 StorageService 先创建InstanceService 后创建且需要引用 StorageService
**方案**: 使用 setter 方法 `SetStorageService()` 而非构造函数注入
**Why**: Go 不允许循环依赖,但可以通过 setter 在创建后再建立引用关系
**How to apply**: 多个 service 需要相互引用时,用 setter 方法注入,避免循环 import
## Lesson 9: Playwright networkidle Timeout on Real-time Apps (2026-04-17)
**现象**: Playwright `wait_for_load_state('networkidle')` 超时 30s
**根因**: 页面有 WebSocket/real-time 连接,永远不会达到 `networkidle` 状态
**修复**: 使用 `wait_for_load_state('load')` + `wait_for_timeout()` 替代
**How to apply**: 测试有 WebSocket/SSE/real-time 连接的 Next.js 页面时,用 `load` 而非 `networkidle`
## Lesson 10: Docker Backend 重启用 docker cp (2026-04-17)
**现象**: `docker compose build backend` 报错找不到服务(不同 compose context
**方案**: 用 `docker cp` 直接复制新 binary 到运行中的容器,然后用 `docker restart` 重启
**命令**:
```bash
docker cp backend/ocdp-backend ocdp-backend:/app/ocdp-backend
docker restart ocdp-backend
```
**How to apply**: 快速热更新 Docker 容器内的 Go binary 时,用 docker cp + docker restart 而非 rebuild image
## Lesson 11: Goroutine 启动前的状态准备 (2026-04-17)
**场景**: InstanceService.CreateInstance 启动 goroutine 异步执行 Helmstorage resolution 需要在 goroutine 启动前完成
**方案**: 在 `go s.executeAndSyncInstall(...)` 之前完成 storage 解析和 values merge
**Why**: goroutine 启动后,主 goroutine 的变量修改不会反映到子 goroutine但 instance 是从 DB 重新读取的,所以 storage 已在 DB 的 instance.Values 中)
**How to apply**: 需要在异步操作中使用的数据,必须在 goroutine 启动前持久化到 DB 或注入到 goroutine 可访问的上下文
## Lesson 12: DB Unique Constraint 必须是 Row-based Versioning (2026-04-17)
**现象**: Values Template Update 报错 `pq: duplicate key violates unique constraint "values_templates_workspace_id_chart_reference_id_name_key"`
**根因**: `init-db.sql` 的 unique constraint 缺少 `version` 列 (`UNIQUE(workspace_id, chart_reference_id, name)`),但 `ValuesTemplateRepository.Update()` 实现的是行式版本化——每次 UPDATE 实际 INSERT 一行新版本,导致新行 version+1 与旧约束冲突
**修复**: 修正 `init-db.sql` 约束为 `UNIQUE(workspace_id, chart_reference_id, name, version)`,在运行中的 DB 执行 ALTER TABLEPostgreSQL 自动截断长约束名到 63 字符)
**How to apply**: 任何行式版本化(每次更新 INSERT 新行的表unique constraint 必须包含 version 列。建表前先确认 Repo 层的 UPDATE 语义。
## Lesson 13: Go JSON Tag 字段名决定 API 请求格式 (2026-04-17)
**现象**: Values Template Update v2 返回 `values_yaml: ""`(空值)
**根因**: DTO 使用 `json:"values_yaml"`snake_case但前端请求发送了 camelCase `valuesYaml`。Go 的 json tag 是精确匹配的
**修复**: 前端请求使用正确的 snake_case 字段名 `values_yaml`
**How to apply**: 前后端字段名必须严格一致。Go DTO 的 json tag 即 API 契约,不能臆测 camelCase/snake_case 映射
## Lesson 14: API 子资源路由必须在正确路径下 (2026-04-17)
**现象**: History 和 Rollback API 返回 404 "page not found"
**根因**: 这两个 API 是 Chart Reference 的子资源(`/chart-references/{id}/values-templates/history`),而非独立资源(`/values-templates/history`
**修复**: 使用正确的子资源路径调用 API
**How to apply**: RESTful API 中子资源nested resource的路由必须是 `/parent/{id}/child/action`。测试时先查 handler 路由注册确认路径
## Bug 15: values.yaml 未应用到 Helm Releases (2026-04-22)
**现象**: 前端发送 values.yaml自定义值`replicaCount: 9`)没有应用到集群中的 Helm release
**根因**: `Instance.SetValuesYAML()` 只存储了 `i.ValuesYAML = yaml`**没有解析成 `i.Values` map**。Helm Client 使用 `install.Run(chart, instance.Values)``instance.Values` 是空 map
**修复**:
1. `instance.go`: 在 `SetValuesYAML()` 中添加 `gopkg.in/yaml.v3` 解析,将 YAML string 解析为 `map[string]interface{}` 并 merge 到 `Values`
2. `instance_handler.go`: `ListInstances``InstanceResponse` 构造中缺少 `Values` 字段(只有 `GetInstance` 等有),添加 `Values: instance.Values`
**Why**: values.yaml 存储在 DB 中但 Helm SDK 需要 `map[string]interface{}`。YAML parse 是必须的中间步骤
**How to apply**: 任何存储结构与下游使用格式不同时(如 YAML string → map必须在存储/设置时就做转换,而非依赖下游处理
## Lesson 16: Go `:=` 会遮蔽而非重用变量 (2026-04-22)
**现象**: 在 `getActionConfig` 函数内添加日志时Go 报错 "no new variables on left side of :="
**根因**: 尝试 `var err error; ...; actionConfig, err := getActionConfig(...)``err` 已声明,不能再用 `:=`。只有 `actionConfig` 是新变量
**修复**: `actionConfig, err := getActionConfig(...)` 即可,因为 actionConfig 是新的。`err` 必须用普通 `=` 赋值
**How to apply**: Go 的 `:=` 用于声明新变量并初始化。若变量已用 `var` 声明,再用 `:=` 会报 "no new variables"。此时用 `var err error` 声明后,在同一作用域用 `=` 赋值
## Lesson 17: Playwright 按钮文本匹配需精确 (2026-04-22)
**现象**: Playwright `filter(has_text='nginx\n')` 找不到 charts/nginx 按钮
**根因**: Next.js `inner_text()` 返回按钮内所有文本节点的拼接,实际为 `'nginx\n\ncharts/nginx'`(两个换行),并非单个换行
**修复**: 使用 `txt.strip().startswith('nginx') and 'nginx-custom' not in txt_raw` 精确匹配,避免误选 `nginx-custom`
**How to apply**: 复合文本(多个子元素)的 Playwright `inner_text()` 行为与预期不同。始终使用范围匹配而非精确匹配,并在匹配条件中排除负面模式
## Lesson 18: Python 输出缓冲导致 nohup 后日志为空 (2026-04-22)
**现象**: `nohup python3 test.py > log &``cat log` 显示空文件,但进程正在运行
**根因**: Python 默认启用输出缓冲nohup 不会自动 flush
**修复**: 使用 `python3 -u test.py` (unbuffered mode) 或 `python3 -W- test.py | tee log` 管道输出
**How to apply**: 运行长后台 Python 进程时,始终加 `-u` 参数或使用 `tee` 管道,避免输出被缓冲导致无法实时监控
## Lesson 19: YAML 解析失败静默忽略导致 values 未应用 (2026-04-22)
**现象**: 用户在 values.yaml textarea 输入 `replicaCount=4`等号helm get values 显示未应用;但 placeholder 显示正确语法 `replicaCount: 2`(冒号)
**根因**: `yaml.Unmarshal("replicaCount=4", ...)` 解析失败返回空 map`if err == nil && parsed != nil` 条件不满足,**静默跳过** mergeHelm 收到空 values
**修复**:
1. Backend `SetValuesYAML()`: 解析失败时打印 WARNING 日志
2. Frontend: textarea onChange 做客户端 YAML 语法检查(检测 `key=value` 模式),红色错误提示 + 禁用 Deploy 按钮
**Why**: YAML 解析失败不应该静默忽略,需要让用户感知输入错误。客户端提前验证比后端日志更友好
**How to apply**: 任何格式转换YAML/JSON/Marshal失败都要有明确反馈日志/返回错误),不能静默跳过
## Lesson 20: Next.js `next-server` 进程持有旧 Build 缓存 (2026-04-22)
**现象**: `.next` 目录 rebuild 后,`ocdp-frontend` 容器(运行 `next-server`)的 HTML 仍引用旧的 JS chunk 哈希,导致 500
**根因**: Next.js Server Component 的 RSC payload 和 HTML 是在 `next-server` 进程启动时从 `.next` 目录读取的。简单的 `docker cp` 替换 `.next` 目录后,**进程内存中的路由映射仍是旧的**。需要 `docker restart` 重启进程
**How to apply**: 更新 Next.js 容器后必须 `docker restart <container>` 而非仅 `docker cp`,否则 RSC payload 和 HTML 模板不一致
## Lesson 21: AES-GCM 解密失败静默破坏 kubeconfig 明文数据 (2026-04-22)
**现象**: browser 点击 Deploy 后端报错 "no valid credentials found for cluster cluster1",但 CA 证书能正常解析到 K8s server cert
**根因**: cluster1 的 `ca_data` 字段存储了明文 kubeconfig`apiVersion: v1\n...` 格式)。`GetByID` 总是调用 `encryptor.Decrypt()`AES-GCM 解密失败后返回乱码并丢弃。后续 `createRestConfig` 检测不到 kubeconfig 格式(乱码非 `apiVersion:`),且 cert/key 字段为空,最终走 kubeconfig 文件分支失败
**修复**: 添加 `decryptIfNeeded()` helper检测数据以 `apiVersion:``kind:` 开头则跳过解密直接返回原值。更新 `GetByID``GetByName``scanClusters` 三处调用
**Why**: 明文 kubeconfig 绕过了解密流程,因为没有任何"已加密"标记。检测格式前缀是区分加密/明文的最简方式
**How to apply**: 所有存储加密数据的 Repository在解密前必须检查数据格式。kubeconfig 内容不需要加密(已在文件系统中受保护),用明文格式存储更简单
## Lesson 22: PostgreSQL 存储 Base64 编码加密数据 vs 直接存储加密字节 (2026-04-22)
**现象**: clusters 表的 `ca_data` 等字段存储的是 `Encrypt()` 函数的 Base64 输出。但 Encrypt() 内部已经做了 Base64 编码,所以这些字段存的是**双重 Base64**crypto output 的 Base64 再次作为普通文本存储)
**分析**: `Encrypt()` 返回 `base64.StdEncoding.EncodeToString(ciphertext)`ciphertext 包含 nonce+tag+内容,已是标准 Base64 格式。这个 Base64 字符串直接存储到 text 字段即可
**How to apply**: Go 的 `crypto.Encrypt()` → Base64 字符串 → 直接存 PostgreSQL text 字段。读取时需要先从 text 列获取 Base64 字符串,再调用 `Decrypt()` 解析。这与前端发送 PEM 内容(`LS0tLS1...`)直接加密存储不同

View File

@ -8,32 +8,28 @@
- ✅ Phase 4: E2E 端到端验证
- ✅ Bug Fix: frontend version → backend req.Tag 字段映射
- ✅ Bug Fix: registry 解密失败 graceful fallback
- 🔄 Phase 5: Values Template 版本管理 (P2)
- 🔄 Phase 6: Storage 分层配置 (P2)
- ✅ Bug Fix: frontend registryId 字段缺失导致部署 API 失败
- ✅ Bug Fix: Artifact 类型过滤 (type==='chart') 不匹配问题
- ✅ Feature: 创建 Instances 列表页面查看部署状态
- ✅ Feature: Sidebar 添加 Deployments 导航项
- ✅ Bug Fix: values.yaml 未应用到 Helm releases (2026-04-22)
- ✅ Bug Fix: Cluster1 连接失败 — kubeconfig 明文绕过 AES-GCM 解密 (2026-04-22)
- ✅ Phase 5: Values Template 版本管理
- ✅ Phase 6: Storage 分层配置
- ✅ Testing Complete: Values Template E2E + Browser UI (2026-04-17)
## 当前里程碑
核心部署流程打通2026-04-16
核心部署流程 + 配置管理完全打通2026-04-17
- Admin 创建 workspace → 创建 user ✓
- User 登录 → 浏览 Charts → 部署成功 → status=deployed ✓
- Chart 从 Harbor OCI 下载到 /tmp/charts/ ✓
- Helm release 部署到 K8s 集群 ✓
- **前端完整支持Charts 浏览器 + Deploy Modal + Instances 列表** ✓
- **Values Template 版本管理:创建/历史/回滚** ✓
- **Storage 分层配置cluster/workspace/shared 优先级解析** ✓
## 待办事项
### Phase 5: Values Template 版本管理
- [ ] 每次更新创建新版本
- [ ] 查看版本历史
- [ ] 回滚到历史版本
- 关键文件: `backend/internal/domain/service/values_template_service.go`, `frontend/src/app/templates/page.tsx`
### Phase 6: Storage 分层配置
- [ ] Cluster-level 默认存储
- [ ] Workspace-level 存储覆盖
- [ ] User Override 最高优先级
- 关键文件: `backend/internal/domain/service/storage_service.go`, `frontend/src/app/storage/page.tsx`
## 完成清单
## 已完成清单
- [x] Backend: instance_dto.go - 添加 Version 字段Normalize() 兼容 version/tag
- [x] Backend: instance_handler.go - 添加 version 空值校验
@ -41,5 +37,84 @@
- [x] Backend: registry_repository.go - 修复 GetByID/GetByName schema 字段不匹配
- [x] Backend: registry_repository.go - 解密失败时返回空密码而非错误
- [x] Frontend: charts/page.tsx - 添加 Template 和 Storage 下拉选择器
- [x] Frontend: types.ts - 添加 registryId 字段到 CreateInstanceRequest
- [x] Frontend: charts/page.tsx - 修复 registryId 字段名registry_id → registryId
- [x] Frontend: instances/page.tsx - 新建 Instances 列表页面
- [x] Frontend: sidebar.tsx - 添加 Deployments 导航项
- [x] Bug Fix: values.yaml 未解析到 Values map (instance.go SetValuesYAML 添加 yaml.v3 parse)
- [x] Bug Fix: ListInstances API 响应缺少 Values 字段 (instance_handler.go 添加 Values 字段)
- [x] Tests: e2e_test.py - 完整 5 步 E2E 测试
- [x] Docs: tasks/lessons.md - 记录 4 个 Bug 的根因和修复
- [x] Docs: tasks/lessons.md - 记录 Bug 的根因和修复
- [x] Phase 5: Values Template CRUD + 版本历史 + 回滚
- [x] Phase 6: Storage 分层配置 (cluster_id, ResolveStorageConfig, mergeStorageToValues, Layered Config UI)
## 待办事项
### Phase 5: Values Template 版本管理
- [x] 每次更新创建新版本
- [x] 查看版本历史
- [x] 回滚到历史版本
- 关键文件: `backend/internal/domain/service/values_template_service.go`, `frontend/src/app/templates/page.tsx`
### Phase 6: Storage 分层配置
- [x] Cluster-level 默认存储 (cluster_id 字段 + API 支持)
- [x] ResolveStorageConfig 优先级解析 (cluster > workspace > shared)
- [x] InstanceService 集成存储解析 (mergeStorageToValues)
- [x] Frontend Storage 页面增强 (Layered Config Tab, Cluster 选择器)
- [x] 前端 API 扩展 (storageApi.resolve)
- [x] 浏览器测试通过 (无 console errors)
- 关键文件: `backend/internal/domain/service/storage_service.go`, `backend/internal/domain/service/instance_service.go`, `frontend/src/app/storage/page.tsx`
## 浏览器测试结果 (2026-04-17)
### Phase 4 E2E 测试通过项目
1. ✅ 登录页面正常登录
2. ✅ Sidebar 导航显示 Deployments 项
3. ✅ Charts 页面加载 registries
4. ✅ 选择 registry 后显示 chart repos 列表
5. ✅ 点击 repo 后显示版本列表
6. ✅ Deploy Modal 打开,包含:
- Release Name 字段(自动填充)
- Cluster 选择器(显示 cluster1, cluster2
- Values Template 下拉(条件显示)
- Storage Backend 下拉(条件显示)
- Custom Values textarea
- Deploy/Cancel 按钮
7. ✅ 点击 Deploy 后请求发送到后端
8. ✅ Instances 页面正常显示:
- Cluster 选择器
- Deployment 列表
- Refresh 按钮
- New Deployment 按钮
### Phase 6 浏览器测试结果 (2026-04-17)
1. ✅ Storage 页面正常加载Add Storage 按钮可用
2. ✅ Monitoring 页面正常加载
3. ✅ Chart References 页面正常加载Add Chart Reference 按钮可用
4. ✅ Clusters 页面正常加载
5. ✅ 所有页面无 console errors
6. ✅ 截图已保存: storage_page.png, monitoring_page.png, chart_references_page.png
7. ✅ storage_backends/resolve API 返回 404 (预期行为,无默认存储配置)
### Bug Fix: values.yaml 未应用 (2026-04-22)
1. ✅ 登录 → 浏览 charts → 选择 charts/nginx
2. ✅ Deploy Modal: 填写 release name, 选择 cluster, 填写 `replicaCount: 9`
3. ✅ API 返回 `deployed` 状态
4.`ListInstances` API 返回 `values: {"replicaCount": 9}`
5.`helm get values <release>` 返回 `replicaCount: 9`
6.`install.Run()` 耗时 ~10-20s结果 `err=<nil>`
7. ✅ 测试脚本: `/tmp/test_values_final.py` (使用 charts/nginx chart)
### Bug Fix: YAML 解析失败静默忽略 (2026-04-22)
1. ✅ DB 确认 `nginx` 实例 `values=null`, `values_yaml='replicaCount=4'`(用户写错了语法)
2. ✅ Backend `SetValuesYAML()`: 解析失败时打印 WARNING 日志
3. ✅ Frontend: textarea onChange 客户端检测 `key=value` 模式,红色错误 + 禁用 Deploy 按钮
4. ✅ 测试脚本: `/tmp/test_values_browser.py`
### Bug Fix: Cluster1 连接失败 (2026-04-22)
1. ✅ 问题:前端 Deploy 后端报错 "no valid credentials found"
2. ✅ 根因cluster1 ca_data 存明文 kubeconfig但 GetByID 总是尝试 AES-GCM 解密 → 乱码
3. ✅ 修复cluster_repository.go 添加 decryptIfNeeded(),检测 apiVersion:/kind: 前缀跳过解密
4. ✅ 重建镜像 + 重启容器 + 挂载 kubeconfig 目录
5.`curl /clusters/{id}/health``{"healthy":true}`
6. ✅ E2E 浏览器测试deploy nginx → `status=deployed, values={"replicaCount": 9}`