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:
17
.gitignore
vendored
17
.gitignore
vendored
@ -36,6 +36,9 @@ build/
|
|||||||
backend/bin/
|
backend/bin/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
|
|
||||||
|
# Compiled binaries
|
||||||
|
backend/ocdp-backend
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
@ -61,3 +64,17 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
*.tmp
|
*.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
127
Multi-Tenant Kubeconfig.md
Normal 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.
|
||||||
@ -156,6 +156,9 @@ func main() {
|
|||||||
storageService := service.NewStorageService(repos.StorageRepo)
|
storageService := service.NewStorageService(repos.StorageRepo)
|
||||||
storageHandler := rest.NewStorageHandler(storageService)
|
storageHandler := rest.NewStorageHandler(storageService)
|
||||||
|
|
||||||
|
// Wire storage service into instance service for layered storage config
|
||||||
|
instanceService.SetStorageService(storageService)
|
||||||
|
|
||||||
// Chart Reference Handler
|
// Chart Reference Handler
|
||||||
chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo)
|
chartRefService := service.NewChartReferenceService(repos.ChartRefRepo, repos.RegistryRepo)
|
||||||
chartRefHandler := rest.NewChartReferenceHandler(chartRefService)
|
chartRefHandler := rest.NewChartReferenceHandler(chartRefService)
|
||||||
@ -377,6 +380,7 @@ func setupRouter(
|
|||||||
// ===== Storage Backend 路由 =====
|
// ===== Storage Backend 路由 =====
|
||||||
api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost)
|
api.HandleFunc("/storage-backends", storageHandler.CreateStorage).Methods(http.MethodPost)
|
||||||
api.HandleFunc("/storage-backends", storageHandler.GetAllStorage).Methods(http.MethodGet)
|
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.GetStorage).Methods(http.MethodGet)
|
||||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut)
|
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.UpdateStorage).Methods(http.MethodPut)
|
||||||
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete)
|
api.HandleFunc("/storage-backends/{storage_id}", storageHandler.DeleteStorage).Methods(http.MethodDelete)
|
||||||
|
|||||||
@ -61,19 +61,37 @@ services:
|
|||||||
image: ocdp-backend:latest
|
image: ocdp-backend:latest
|
||||||
container_name: ocdp-backend
|
container_name: ocdp-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- /media/ivanwu/DATA/ocdp-go/.env
|
||||||
environment:
|
environment:
|
||||||
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
ADAPTER_MODE: ${ADAPTER_MODE:-production}
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-change-me-32-bytes-long-key-here}
|
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
|
DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-ocdp}?sslmode=disable
|
||||||
KUBECONFIG: ${KUBECONFIG:-.kube/config}
|
KUBECONFIG: ""
|
||||||
HARBOR_URL: ${HARBOR_URL:-}
|
|
||||||
HARBOR_USERNAME: ${HARBOR_USERNAME:-}
|
|
||||||
HARBOR_PASSWORD: ${HARBOR_PASSWORD:-}
|
|
||||||
NFS_SERVER: ${NFS_SERVER:-}
|
|
||||||
NFS_SHARE: ${NFS_SHARE:-}
|
|
||||||
ALLOWED_DEV_ORIGINS: ${ALLOWED_DEV_ORIGINS:-*}
|
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:
|
ports:
|
||||||
- "${BACKEND_PORT:-8080}:8080"
|
- "${BACKEND_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -76,6 +76,7 @@ func WorkspaceDTOFromEntity(workspace *entity.Workspace) *WorkspaceDTO {
|
|||||||
return &WorkspaceDTO{
|
return &WorkspaceDTO{
|
||||||
ID: workspace.ID,
|
ID: workspace.ID,
|
||||||
Name: workspace.Name,
|
Name: workspace.Name,
|
||||||
|
ClusterIDs: workspace.ClusterIDs,
|
||||||
Description: workspace.Description,
|
Description: workspace.Description,
|
||||||
CreatedBy: workspace.CreatedBy,
|
CreatedBy: workspace.CreatedBy,
|
||||||
CreatedAt: workspace.CreatedAt,
|
CreatedAt: workspace.CreatedAt,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ type CreateStorageRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
IsShared bool `json:"is_shared"`
|
IsShared bool `json:"is_shared"`
|
||||||
|
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
|
||||||
|
|
||||||
// NFS 配置
|
// NFS 配置
|
||||||
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
||||||
@ -23,6 +24,7 @@ type UpdateStorageRequest struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
IsShared bool `json:"is_shared"`
|
IsShared bool `json:"is_shared"`
|
||||||
|
ClusterID string `json:"cluster_id,omitempty"` // 用于 cluster-level storage
|
||||||
|
|
||||||
// NFS 配置
|
// NFS 配置
|
||||||
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
NFS NFSConfigDTO `json:"nfs,omitempty"`
|
||||||
@ -52,17 +54,18 @@ type HostPathConfigDTO struct {
|
|||||||
|
|
||||||
// StorageResponse 存储后端响应
|
// StorageResponse 存储后端响应
|
||||||
type StorageResponse struct {
|
type StorageResponse struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
WorkspaceID string `json:"workspace_id,omitempty"`
|
WorkspaceID string `json:"workspace_id,omitempty"`
|
||||||
OwnerID string `json:"owner_id,omitempty"`
|
ClusterID string `json:"cluster_id,omitempty"`
|
||||||
Name string `json:"name"`
|
OwnerID string `json:"owner_id,omitempty"`
|
||||||
Type string `json:"type"`
|
Name string `json:"name"`
|
||||||
Config StorageConfigDTO `json:"config"`
|
Type string `json:"type"`
|
||||||
Description string `json:"description"`
|
Config StorageConfigDTO `json:"config"`
|
||||||
IsDefault bool `json:"is_default"`
|
Description string `json:"description"`
|
||||||
IsShared bool `json:"is_shared"`
|
IsDefault bool `json:"is_default"`
|
||||||
CreatedAt string `json:"createdAt"`
|
IsShared bool `json:"is_shared"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StorageConfigDTO 存储配置(脱敏后)
|
// StorageConfigDTO 存储配置(脱敏后)
|
||||||
|
|||||||
@ -4,24 +4,32 @@ import "time"
|
|||||||
|
|
||||||
// WorkspaceDTO 工作空间 DTO
|
// WorkspaceDTO 工作空间 DTO
|
||||||
type WorkspaceDTO struct {
|
type WorkspaceDTO struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description,omitempty"`
|
ClusterIDs []string `json:"cluster_ids,omitempty"`
|
||||||
CreatedBy string `json:"created_by"`
|
Quotas []*QuotaDTO `json:"quotas,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
Description string `json:"description,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
CreatedBy string `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWorkspaceRequest 创建工作空间请求
|
// CreateWorkspaceRequest 创建工作空间请求(包含配额设置)
|
||||||
type CreateWorkspaceRequest struct {
|
type CreateWorkspaceRequest struct {
|
||||||
Name string `json:"name" validate:"required"`
|
Name string `json:"name" validate:"required"`
|
||||||
Description string `json:"description"`
|
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 更新工作空间请求
|
// UpdateWorkspaceRequest 更新工作空间请求
|
||||||
type UpdateWorkspaceRequest struct {
|
type UpdateWorkspaceRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
ClusterIDs []string `json:"cluster_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuotaDTO 配额 DTO
|
// QuotaDTO 配额 DTO
|
||||||
|
|||||||
@ -83,14 +83,14 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 获取用户信息
|
// 获取用户信息
|
||||||
// TODO: 从 token 解析用户信息或从服务获取
|
// TODO: 从 token 解析用户信息或从服务获取
|
||||||
|
|
||||||
// 返回响应
|
// 返回响应 - 使用 respondSuccess 包装,与其他 API 保持一致
|
||||||
response := &dto.AuthResponse{
|
response := &dto.AuthResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondSuccess(w, "Login successful", response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshToken 刷新 Token
|
// RefreshToken 刷新 Token
|
||||||
@ -117,11 +117,11 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回响应
|
// 返回响应 - 使用 respondSuccess 包装
|
||||||
response := &dto.AuthResponse{
|
response := &dto.AuthResponse{
|
||||||
AccessToken: newAccessToken,
|
AccessToken: newAccessToken,
|
||||||
RefreshToken: req.RefreshToken,
|
RefreshToken: req.RefreshToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, response)
|
respondSuccess(w, "Token refreshed", response)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -188,6 +188,7 @@ func (h *InstanceHandler) ListInstances(w http.ResponseWriter, r *http.Request)
|
|||||||
LastOperation: string(instance.LastOperation),
|
LastOperation: string(instance.LastOperation),
|
||||||
LastError: instance.LastError,
|
LastError: instance.LastError,
|
||||||
Revision: instance.Revision,
|
Revision: instance.Revision,
|
||||||
|
Values: instance.Values,
|
||||||
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
CreatedAt: instance.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
UpdatedAt: instance.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||||
})
|
})
|
||||||
|
|||||||
@ -10,6 +10,14 @@ import (
|
|||||||
"github.com/ocdp/cluster-service/internal/domain/service"
|
"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
|
// StorageHandler Storage Backend Handler
|
||||||
type StorageHandler struct {
|
type StorageHandler struct {
|
||||||
storageService *service.StorageService
|
storageService *service.StorageService
|
||||||
@ -77,6 +85,7 @@ func (h *StorageHandler) CreateStorage(w http.ResponseWriter, r *http.Request) {
|
|||||||
req.Description,
|
req.Description,
|
||||||
req.IsDefault,
|
req.IsDefault,
|
||||||
req.IsShared,
|
req.IsShared,
|
||||||
|
req.ClusterID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "Failed to create storage backend", err.Error())
|
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)
|
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
|
// toStorageResponse 转换为响应 DTO
|
||||||
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
|
func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
|
||||||
config := dto.StorageConfigDTO{}
|
config := dto.StorageConfigDTO{}
|
||||||
@ -278,6 +326,7 @@ func toStorageResponse(storage *entity.StorageBackend) *dto.StorageResponse {
|
|||||||
return &dto.StorageResponse{
|
return &dto.StorageResponse{
|
||||||
ID: storage.ID,
|
ID: storage.ID,
|
||||||
WorkspaceID: storage.WorkspaceID,
|
WorkspaceID: storage.WorkspaceID,
|
||||||
|
ClusterID: storage.ClusterID,
|
||||||
OwnerID: storage.OwnerID,
|
OwnerID: storage.OwnerID,
|
||||||
Name: storage.Name,
|
Name: storage.Name,
|
||||||
Type: string(storage.Type),
|
Type: string(storage.Type),
|
||||||
|
|||||||
@ -26,7 +26,7 @@ func NewWorkspaceHandler(workspaceService *service.WorkspaceService, authService
|
|||||||
|
|
||||||
// CreateWorkspace 创建工作空间
|
// CreateWorkspace 创建工作空间
|
||||||
// @Summary 创建工作空间
|
// @Summary 创建工作空间
|
||||||
// @Description 创建新的工作空间(Admin 专用)
|
// @Description 创建新的工作空间(Admin 专用,支持 cluster_ids 和初始配额)
|
||||||
// @Tags workspace
|
// @Tags workspace
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@ -48,7 +48,31 @@ func (h *WorkspaceHandler) CreateWorkspace(w http.ResponseWriter, r *http.Reques
|
|||||||
// 获取创建者 ID
|
// 获取创建者 ID
|
||||||
userID := GetUserIDFromRequest(r)
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||||
return
|
return
|
||||||
@ -129,6 +153,9 @@ func (h *WorkspaceHandler) UpdateWorkspace(w http.ResponseWriter, r *http.Reques
|
|||||||
if req.Description != "" {
|
if req.Description != "" {
|
||||||
workspace.Description = req.Description
|
workspace.Description = req.Description
|
||||||
}
|
}
|
||||||
|
if req.ClusterIDs != nil {
|
||||||
|
workspace.ClusterIDs = req.ClusterIDs
|
||||||
|
}
|
||||||
|
|
||||||
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
|
if err := h.workspaceService.Update(r.Context(), workspace); err != nil {
|
||||||
respondError(w, http.StatusBadRequest, err.Error(), "")
|
respondError(w, http.StatusBadRequest, err.Error(), "")
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
@ -113,22 +114,24 @@ func (h *HelmClient) Install(ctx context.Context, cluster *entity.Cluster, insta
|
|||||||
install.Namespace = instance.Namespace
|
install.Namespace = instance.Namespace
|
||||||
install.CreateNamespace = true
|
install.CreateNamespace = true
|
||||||
install.Wait = true
|
install.Wait = true
|
||||||
install.Timeout = 5 * time.Minute
|
install.Timeout = 1 * time.Minute
|
||||||
|
|
||||||
// 加载 Chart(从本地路径或 OCI registry)
|
// 加载 Chart(从本地路径或 OCI registry)
|
||||||
// 这里简化处理,假设 chart 已经被拉取到本地
|
|
||||||
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
chartPath := fmt.Sprintf("/tmp/charts/%s-%s.tgz", instance.Chart, instance.Version)
|
||||||
|
|
||||||
chart, err := loader.Load(chartPath)
|
chart, err := loader.Load(chartPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load chart: %w", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to install release: %w", err)
|
return fmt.Errorf("failed to install release: %w", err)
|
||||||
}
|
}
|
||||||
|
log.Printf("[helm-install] step=done instance=%s revision=%d", instance.Name, rel.Version)
|
||||||
|
|
||||||
// 更新 revision(状态由调用方根据操作结果设置)
|
// 更新 revision(状态由调用方根据操作结果设置)
|
||||||
instance.Revision = rel.Version
|
instance.Revision = rel.Version
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -74,23 +75,147 @@ func (c *OCIClient) getRegistry(reg *entity.Registry) (*remote.Registry, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListRepositories 列出 Registry 中的所有 repositories
|
// ListRepositories 列出 Registry 中的所有 repositories
|
||||||
|
// 优先使用 OCI _catalog API,失败时回退到 Harbor REST API v2
|
||||||
func (c *OCIClient) ListRepositories(ctx context.Context, registry *entity.Registry) ([]string, error) {
|
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)
|
repositories := make([]string, 0)
|
||||||
|
|
||||||
err = reg.Repositories(ctx, "", func(repos []string) error {
|
// 尝试 OCI _catalog API
|
||||||
repositories = append(repositories, repos...)
|
reg, err := c.getRegistry(registry)
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list repositories: %w", err)
|
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
|
return repositories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -83,7 +83,28 @@ func (r *StorageRepositoryMock) GetDefault(ctx context.Context, workspaceID stri
|
|||||||
return s, nil
|
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 列出所有存储(管理员用)
|
// List 列出所有存储(管理员用)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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)
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解密敏感数据
|
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||||
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
|
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
|
||||||
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
|
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
|
||||||
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
|
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
|
||||||
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
|
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
|
||||||
|
|
||||||
return cluster, nil
|
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)
|
return nil, fmt.Errorf("failed to get cluster: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解密敏感数据
|
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||||
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData)
|
cluster.CAData = r.decryptIfNeeded(encryptedCAData, "ca_data")
|
||||||
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData)
|
cluster.CertData = r.decryptIfNeeded(encryptedCertData, "cert_data")
|
||||||
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData)
|
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData, "key_data")
|
||||||
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken)
|
cluster.Token = r.decryptIfNeeded(encryptedToken, "token")
|
||||||
|
|
||||||
return cluster, nil
|
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 更新集群
|
// Update 更新集群
|
||||||
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
func (r *ClusterRepository) Update(ctx context.Context, cluster *entity.Cluster) error {
|
||||||
cluster.UpdatedAt = time.Now()
|
cluster.UpdatedAt = time.Now()
|
||||||
@ -352,18 +373,18 @@ func (r *ClusterRepository) scanClusters(rows *sql.Rows) ([]*entity.Cluster, err
|
|||||||
cluster.OwnerID = ownerID.String
|
cluster.OwnerID = ownerID.String
|
||||||
cluster.DefaultNamespace = defaultNamespace.String
|
cluster.DefaultNamespace = defaultNamespace.String
|
||||||
|
|
||||||
// 解密敏感数据
|
// 解密敏感数据(检测 kubeconfig 格式则跳过解密)
|
||||||
if encryptedCAData.Valid {
|
if encryptedCAData.Valid {
|
||||||
cluster.CAData, _ = r.encryptor.Decrypt(encryptedCAData.String)
|
cluster.CAData = r.decryptIfNeeded(encryptedCAData.String, "ca_data")
|
||||||
}
|
}
|
||||||
if encryptedCertData.Valid {
|
if encryptedCertData.Valid {
|
||||||
cluster.CertData, _ = r.encryptor.Decrypt(encryptedCertData.String)
|
cluster.CertData = r.decryptIfNeeded(encryptedCertData.String, "cert_data")
|
||||||
}
|
}
|
||||||
if encryptedKeyData.Valid {
|
if encryptedKeyData.Valid {
|
||||||
cluster.KeyData, _ = r.encryptor.Decrypt(encryptedKeyData.String)
|
cluster.KeyData = r.decryptIfNeeded(encryptedKeyData.String, "key_data")
|
||||||
}
|
}
|
||||||
if encryptedToken.Valid {
|
if encryptedToken.Valid {
|
||||||
cluster.Token, _ = r.encryptor.Decrypt(encryptedToken.String)
|
cluster.Token = r.decryptIfNeeded(encryptedToken.String, "token")
|
||||||
}
|
}
|
||||||
|
|
||||||
clusters = append(clusters, cluster)
|
clusters = append(clusters, cluster)
|
||||||
|
|||||||
@ -12,6 +12,14 @@ import (
|
|||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"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 存储后端仓储实现
|
// StorageRepository PostgreSQL 存储后端仓储实现
|
||||||
type StorageRepository struct {
|
type StorageRepository struct {
|
||||||
db *DB
|
db *DB
|
||||||
@ -34,14 +42,15 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB
|
|||||||
}
|
}
|
||||||
|
|
||||||
query := `
|
query := `
|
||||||
INSERT INTO storage_backends (id, workspace_id, owner_id, name, type, config, description, is_default, is_shared, created_at, updated_at)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
`
|
`
|
||||||
|
|
||||||
_, err = r.db.conn.ExecContext(ctx, query,
|
_, err = r.db.conn.ExecContext(ctx, query,
|
||||||
storage.ID,
|
storage.ID,
|
||||||
storage.WorkspaceID,
|
storage.WorkspaceID,
|
||||||
storage.OwnerID,
|
sqlNullString(storage.ClusterID),
|
||||||
|
sqlNullString(storage.OwnerID),
|
||||||
storage.Name,
|
storage.Name,
|
||||||
storage.Type,
|
storage.Type,
|
||||||
configJSON,
|
configJSON,
|
||||||
@ -62,17 +71,19 @@ func (r *StorageRepository) Create(ctx context.Context, storage *entity.StorageB
|
|||||||
// GetByID 根据 ID 获取存储后端
|
// GetByID 根据 ID 获取存储后端
|
||||||
func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
|
func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
storage := &entity.StorageBackend{}
|
storage := &entity.StorageBackend{}
|
||||||
var configJSON []byte
|
var configJSON []byte
|
||||||
|
var wsID, clusterID, ownerID sql.NullString
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
err := r.db.conn.QueryRowContext(ctx, query, id).Scan(
|
||||||
&storage.ID,
|
&storage.ID,
|
||||||
&storage.WorkspaceID,
|
&wsID,
|
||||||
&storage.OwnerID,
|
&clusterID,
|
||||||
|
&ownerID,
|
||||||
&storage.Name,
|
&storage.Name,
|
||||||
&storage.Type,
|
&storage.Type,
|
||||||
&configJSON,
|
&configJSON,
|
||||||
@ -82,6 +93,9 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto
|
|||||||
&storage.CreatedAt,
|
&storage.CreatedAt,
|
||||||
&storage.UpdatedAt,
|
&storage.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
storage.WorkspaceID = wsID.String
|
||||||
|
storage.ClusterID = clusterID.String
|
||||||
|
storage.OwnerID = ownerID.String
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, entity.ErrStorageNotFound
|
return nil, entity.ErrStorageNotFound
|
||||||
@ -100,17 +114,19 @@ func (r *StorageRepository) GetByID(ctx context.Context, id string) (*entity.Sto
|
|||||||
// GetByName 根据名称获取存储后端
|
// GetByName 根据名称获取存储后端
|
||||||
func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
|
func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name string) (*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
WHERE workspace_id = $1 AND name = $2
|
WHERE workspace_id = $1 AND name = $2
|
||||||
`
|
`
|
||||||
|
|
||||||
storage := &entity.StorageBackend{}
|
storage := &entity.StorageBackend{}
|
||||||
var configJSON []byte
|
var configJSON []byte
|
||||||
|
var wsID, clusterID, ownerID sql.NullString
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
|
err := r.db.conn.QueryRowContext(ctx, query, workspaceID, name).Scan(
|
||||||
&storage.ID,
|
&storage.ID,
|
||||||
&storage.WorkspaceID,
|
&wsID,
|
||||||
&storage.OwnerID,
|
&clusterID,
|
||||||
|
&ownerID,
|
||||||
&storage.Name,
|
&storage.Name,
|
||||||
&storage.Type,
|
&storage.Type,
|
||||||
&configJSON,
|
&configJSON,
|
||||||
@ -120,6 +136,9 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str
|
|||||||
&storage.CreatedAt,
|
&storage.CreatedAt,
|
||||||
&storage.UpdatedAt,
|
&storage.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
storage.WorkspaceID = wsID.String
|
||||||
|
storage.ClusterID = clusterID.String
|
||||||
|
storage.OwnerID = ownerID.String
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, entity.ErrStorageNotFound
|
return nil, entity.ErrStorageNotFound
|
||||||
@ -138,7 +157,7 @@ func (r *StorageRepository) GetByName(ctx context.Context, workspaceID, name str
|
|||||||
// GetByWorkspace 获取 workspace 的所有存储后端
|
// GetByWorkspace 获取 workspace 的所有存储后端
|
||||||
func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
|
func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID string) ([]*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
WHERE workspace_id = $1 OR is_shared = TRUE
|
WHERE workspace_id = $1 OR is_shared = TRUE
|
||||||
ORDER BY is_default DESC, name
|
ORDER BY is_default DESC, name
|
||||||
@ -156,7 +175,7 @@ func (r *StorageRepository) GetByWorkspace(ctx context.Context, workspaceID stri
|
|||||||
// GetShared 获取所有共享存储后端
|
// GetShared 获取所有共享存储后端
|
||||||
func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
|
func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
WHERE is_shared = TRUE
|
WHERE is_shared = TRUE
|
||||||
ORDER BY name
|
ORDER BY name
|
||||||
@ -174,7 +193,7 @@ func (r *StorageRepository) GetShared(ctx context.Context) ([]*entity.StorageBac
|
|||||||
// GetDefault 获取 workspace 的默认存储后端
|
// GetDefault 获取 workspace 的默认存储后端
|
||||||
func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
|
func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
WHERE workspace_id = $1 AND is_default = TRUE
|
WHERE workspace_id = $1 AND is_default = TRUE
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@ -182,10 +201,12 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
|
|||||||
|
|
||||||
storage := &entity.StorageBackend{}
|
storage := &entity.StorageBackend{}
|
||||||
var configJSON []byte
|
var configJSON []byte
|
||||||
|
var wsID, clusterID, ownerID sql.NullString
|
||||||
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
|
err := r.db.conn.QueryRowContext(ctx, query, workspaceID).Scan(
|
||||||
&storage.ID,
|
&storage.ID,
|
||||||
&storage.WorkspaceID,
|
&wsID,
|
||||||
&storage.OwnerID,
|
&clusterID,
|
||||||
|
&ownerID,
|
||||||
&storage.Name,
|
&storage.Name,
|
||||||
&storage.Type,
|
&storage.Type,
|
||||||
&configJSON,
|
&configJSON,
|
||||||
@ -195,6 +216,9 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
|
|||||||
&storage.CreatedAt,
|
&storage.CreatedAt,
|
||||||
&storage.UpdatedAt,
|
&storage.UpdatedAt,
|
||||||
)
|
)
|
||||||
|
storage.WorkspaceID = wsID.String
|
||||||
|
storage.ClusterID = clusterID.String
|
||||||
|
storage.OwnerID = ownerID.String
|
||||||
|
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@ -210,6 +234,68 @@ func (r *StorageRepository) GetDefault(ctx context.Context, workspaceID string)
|
|||||||
return storage, nil
|
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 更新存储后端
|
// Update 更新存储后端
|
||||||
func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error {
|
func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageBackend) error {
|
||||||
storage.UpdatedAt = time.Now()
|
storage.UpdatedAt = time.Now()
|
||||||
@ -221,8 +307,8 @@ func (r *StorageRepository) Update(ctx context.Context, storage *entity.StorageB
|
|||||||
|
|
||||||
query := `
|
query := `
|
||||||
UPDATE storage_backends
|
UPDATE storage_backends
|
||||||
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, updated_at = $7
|
SET name = $1, type = $2, config = $3, description = $4, is_default = $5, is_shared = $6, cluster_id = $7, updated_at = $8
|
||||||
WHERE id = $8
|
WHERE id = $9
|
||||||
`
|
`
|
||||||
|
|
||||||
result, err := r.db.conn.ExecContext(ctx, query,
|
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.Description,
|
||||||
storage.IsDefault,
|
storage.IsDefault,
|
||||||
storage.IsShared,
|
storage.IsShared,
|
||||||
|
sqlNullString(storage.ClusterID),
|
||||||
storage.UpdatedAt,
|
storage.UpdatedAt,
|
||||||
storage.ID,
|
storage.ID,
|
||||||
)
|
)
|
||||||
@ -276,7 +363,7 @@ func (r *StorageRepository) Delete(ctx context.Context, id string) error {
|
|||||||
// List 列出所有存储后端(管理员用)
|
// List 列出所有存储后端(管理员用)
|
||||||
func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
func (r *StorageRepository) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||||
query := `
|
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
|
FROM storage_backends
|
||||||
ORDER BY workspace_id, name
|
ORDER BY workspace_id, name
|
||||||
`
|
`
|
||||||
@ -296,9 +383,11 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
storage := &entity.StorageBackend{}
|
storage := &entity.StorageBackend{}
|
||||||
var configJSON []byte
|
var configJSON []byte
|
||||||
|
var wsID, clusterID sql.NullString
|
||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&storage.ID,
|
&storage.ID,
|
||||||
&storage.WorkspaceID,
|
&wsID,
|
||||||
|
&clusterID,
|
||||||
&storage.OwnerID,
|
&storage.OwnerID,
|
||||||
&storage.Name,
|
&storage.Name,
|
||||||
&storage.Type,
|
&storage.Type,
|
||||||
@ -312,6 +401,8 @@ func (r *StorageRepository) scanStorages(rows *sql.Rows) ([]*entity.StorageBacke
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to scan storage: %w", err)
|
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 {
|
if err := json.Unmarshal(configJSON, &storage.Config); err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,15 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BootstrapConfig 预注入配置
|
// BootstrapConfig 预注入配置
|
||||||
type BootstrapConfig struct {
|
type BootstrapConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Users []UserSeed `json:"users"`
|
Users []UserSeed `json:"users"`
|
||||||
Registries []RegistrySeed `json:"registries"`
|
Registries []RegistrySeed `json:"registries"`
|
||||||
Clusters []ClusterSeed `json:"clusters"`
|
Clusters []ClusterSeed `json:"clusters"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSeed 用户预注入数据
|
// UserSeed 用户预注入数据
|
||||||
@ -45,11 +46,11 @@ type ClusterSeed struct {
|
|||||||
|
|
||||||
// LoadBootstrapConfig 加载预注入配置
|
// LoadBootstrapConfig 加载预注入配置
|
||||||
// 支持从文件或环境变量加载
|
// 支持从文件或环境变量加载
|
||||||
//
|
//
|
||||||
// 加载优先级:
|
// 加载优先级:
|
||||||
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
// 1. 环境变量 BOOTSTRAP_CONFIG_JSON (最高优先级)
|
||||||
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
// 2. Mock 模式: 配置文件 config/bootstrap.json
|
||||||
// 3. 真实模式: GetDefaultBootstrapConfig() 中的真实数据
|
// 3. 真实模式: GetDefaultBootstrapConfig() 从 .env 读取
|
||||||
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
||||||
// 1. 优先从环境变量加载
|
// 1. 优先从环境变量加载
|
||||||
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
if configJSON := os.Getenv("BOOTSTRAP_CONFIG_JSON"); configJSON != "" {
|
||||||
@ -62,7 +63,7 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
|||||||
|
|
||||||
// 2. 检查适配器模式
|
// 2. 检查适配器模式
|
||||||
adapterMode := os.Getenv("ADAPTER_MODE")
|
adapterMode := os.Getenv("ADAPTER_MODE")
|
||||||
|
|
||||||
// Mock 模式: 使用配置文件(假数据)
|
// Mock 模式: 使用配置文件(假数据)
|
||||||
if adapterMode == "mock" {
|
if adapterMode == "mock" {
|
||||||
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
configPath := os.Getenv("BOOTSTRAP_CONFIG_FILE")
|
||||||
@ -89,49 +90,87 @@ func LoadBootstrapConfig() (*BootstrapConfig, error) {
|
|||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 真实模式 (mode 1, mode 2): 使用代码中的真实预注入数据
|
// 3. 真实模式 (mode 1, mode 2): 从 .env 读取
|
||||||
return GetDefaultBootstrapConfig(), nil
|
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 {
|
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{
|
return &BootstrapConfig{
|
||||||
Enabled: true,
|
Enabled: len(clusterSeeds) > 0 || len(registrySeeds) > 0 || len(userSeeds) > 0,
|
||||||
Users: []UserSeed{
|
Users: userSeeds,
|
||||||
{
|
Registries: registrySeeds,
|
||||||
Username: "admin",
|
Clusters: clusterSeeds,
|
||||||
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=",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@ -1,9 +1,12 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// InstanceStatus 实例状态
|
// InstanceStatus 实例状态
|
||||||
@ -103,9 +106,31 @@ func (i *Instance) SetValues(values map[string]interface{}) {
|
|||||||
i.UpdatedAt = time.Now()
|
i.UpdatedAt = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetValuesYAML 设置 YAML 格式的 Values
|
// SetValuesYAML 设置 YAML 格式的 Values 并解析到 Values map
|
||||||
func (i *Instance) SetValuesYAML(yaml string) {
|
func (i *Instance) SetValuesYAML(yamlStr string) {
|
||||||
i.ValuesYAML = yaml
|
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()
|
i.UpdatedAt = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,7 @@ type HostPathConfig struct {
|
|||||||
type StorageBackend struct {
|
type StorageBackend struct {
|
||||||
ID string
|
ID string
|
||||||
WorkspaceID string
|
WorkspaceID string
|
||||||
|
ClusterID string // 关联的 cluster,NULL 表示 workspace/shared 级别
|
||||||
OwnerID string
|
OwnerID string
|
||||||
Name string
|
Name string
|
||||||
Type StorageType
|
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 验证存储后端数据
|
// Validate 验证存储后端数据
|
||||||
func (s *StorageBackend) Validate() error {
|
func (s *StorageBackend) Validate() error {
|
||||||
if s.Name == "" {
|
if s.Name == "" {
|
||||||
|
|||||||
@ -9,17 +9,19 @@ type Workspace struct {
|
|||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
CreatedBy string // 创建者用户 ID
|
ClusterIDs []string // 关联的集群 ID 列表
|
||||||
|
CreatedBy string // 创建者用户 ID
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorkspace 创建新工作空间
|
// NewWorkspace 创建新工作空间
|
||||||
func NewWorkspace(name, description, createdBy string) *Workspace {
|
func NewWorkspace(name, description, createdBy string, clusterIDs []string) *Workspace {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return &Workspace{
|
return &Workspace{
|
||||||
Name: name,
|
Name: name,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
ClusterIDs: clusterIDs,
|
||||||
CreatedBy: createdBy,
|
CreatedBy: createdBy,
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
|
|||||||
@ -25,6 +25,12 @@ type StorageRepository interface {
|
|||||||
// GetDefault 获取 workspace 的默认存储后端
|
// GetDefault 获取 workspace 的默认存储后端
|
||||||
GetDefault(ctx context.Context, workspaceID string) (*entity.StorageBackend, error)
|
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 更新存储后端
|
||||||
Update(ctx context.Context, storage *entity.StorageBackend) error
|
Update(ctx context.Context, storage *entity.StorageBackend) error
|
||||||
|
|
||||||
|
|||||||
@ -175,8 +175,8 @@ func (s *ClusterService) createRestConfig(cluster *entity.Cluster) (*rest.Config
|
|||||||
kubeconfig = ".kube/config"
|
kubeconfig = ".kube/config"
|
||||||
}
|
}
|
||||||
// 尝试从文件加载 kubeconfig
|
// 尝试从文件加载 kubeconfig
|
||||||
if _, err := os.Stat(kubeconfig); err == nil {
|
if _, err := os.Stat(kubeconfig); err != nil {
|
||||||
return clientcmd.BuildConfigFromFlags("", kubeconfig)
|
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)
|
return clientcmd.BuildConfigFromFlags("", kubeconfig)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -16,12 +17,13 @@ import (
|
|||||||
|
|
||||||
// InstanceService Helm 实例管理领域服务
|
// InstanceService Helm 实例管理领域服务
|
||||||
type InstanceService struct {
|
type InstanceService struct {
|
||||||
instanceRepo repository.InstanceRepository
|
instanceRepo repository.InstanceRepository
|
||||||
clusterRepo repository.ClusterRepository
|
clusterRepo repository.ClusterRepository
|
||||||
registryRepo repository.RegistryRepository
|
registryRepo repository.RegistryRepository
|
||||||
helmClient repository.HelmClient
|
helmClient repository.HelmClient
|
||||||
ociClient repository.OCIClient
|
ociClient repository.OCIClient
|
||||||
entryClient repository.InstanceEntryClient
|
entryClient repository.InstanceEntryClient
|
||||||
|
storageService *StorageService // for layered storage config resolution
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInstanceService 创建实例服务
|
// NewInstanceService 创建实例服务
|
||||||
@ -34,15 +36,21 @@ func NewInstanceService(
|
|||||||
entryClient repository.InstanceEntryClient,
|
entryClient repository.InstanceEntryClient,
|
||||||
) *InstanceService {
|
) *InstanceService {
|
||||||
return &InstanceService{
|
return &InstanceService{
|
||||||
instanceRepo: instanceRepo,
|
instanceRepo: instanceRepo,
|
||||||
clusterRepo: clusterRepo,
|
clusterRepo: clusterRepo,
|
||||||
registryRepo: registryRepo,
|
registryRepo: registryRepo,
|
||||||
helmClient: helmClient,
|
helmClient: helmClient,
|
||||||
ociClient: ociClient,
|
ociClient: ociClient,
|
||||||
entryClient: entryClient,
|
entryClient: entryClient,
|
||||||
|
storageService: nil, // set via SetStorageService for layered storage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetStorageService 设置存储服务(用于分层存储配置解析)
|
||||||
|
func (s *InstanceService) SetStorageService(storageService *StorageService) {
|
||||||
|
s.storageService = storageService
|
||||||
|
}
|
||||||
|
|
||||||
const chartCacheDir = "/tmp/charts"
|
const chartCacheDir = "/tmp/charts"
|
||||||
|
|
||||||
func (s *InstanceService) chartArchivePath(instance *entity.Instance) string {
|
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
|
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")
|
instance.BeginOperation(entity.OperationInstall, "Preparing installation")
|
||||||
|
|
||||||
// 先写入数据库,记录 pending 状态
|
// 先写入数据库,记录 pending 状态
|
||||||
@ -104,7 +126,16 @@ func (s *InstanceService) CreateInstance(ctx context.Context, instance *entity.I
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 异步执行 Helm 安装并监控状态
|
// 异步执行 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
|
return nil
|
||||||
@ -286,8 +317,10 @@ func (s *InstanceService) ListInstanceEntries(ctx context.Context, clusterID, in
|
|||||||
|
|
||||||
// executeAndSyncInstall 异步执行安装并监控状态
|
// executeAndSyncInstall 异步执行安装并监控状态
|
||||||
func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID string, cluster *entity.Cluster, registry *entity.Registry, instance *entity.Instance) {
|
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 安装
|
// 执行 Helm 安装
|
||||||
if err := s.helmClient.Install(ctx, cluster, instance); err != nil {
|
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)
|
instance, updateErr := s.instanceRepo.GetByID(ctx, instanceID)
|
||||||
if updateErr == nil && instance != nil {
|
if updateErr == nil && instance != nil {
|
||||||
@ -296,6 +329,7 @@ func (s *InstanceService) executeAndSyncInstall(ctx context.Context, instanceID
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[install-ok] instanceID=%s revision=%d", instanceID, instance.Revision)
|
||||||
|
|
||||||
// 安装成功后,同步状态
|
// 安装成功后,同步状态
|
||||||
s.syncInstanceStatus(ctx, instanceID, cluster, instance.Name, instance.Namespace, entity.OperationInstall)
|
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)
|
_ = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/ocdp/cluster-service/internal/domain/entity"
|
"github.com/ocdp/cluster-service/internal/domain/entity"
|
||||||
"github.com/ocdp/cluster-service/internal/domain/repository"
|
"github.com/ocdp/cluster-service/internal/domain/repository"
|
||||||
@ -13,6 +15,13 @@ var (
|
|||||||
ErrStorageExists = errors.New("storage backend already exists")
|
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 存储后端领域服务
|
// StorageService 存储后端领域服务
|
||||||
type StorageService struct {
|
type StorageService struct {
|
||||||
storageRepo repository.StorageRepository
|
storageRepo repository.StorageRepository
|
||||||
@ -33,14 +42,20 @@ func (s *StorageService) Create(
|
|||||||
config entity.StorageConfig,
|
config entity.StorageConfig,
|
||||||
description string,
|
description string,
|
||||||
isDefault, isShared bool,
|
isDefault, isShared bool,
|
||||||
|
clusterID string,
|
||||||
) (*entity.StorageBackend, error) {
|
) (*entity.StorageBackend, error) {
|
||||||
// 检查名称是否已存在
|
// 检查名称是否已存在(同一 workspace 或同一 cluster 下不能重复)
|
||||||
existing, _ := s.storageRepo.GetByName(ctx, workspaceID, name)
|
existing, _ := s.storageRepo.GetByName(ctx, workspaceID, name)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
return nil, ErrStorageExists
|
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.Description = description
|
||||||
storage.IsDefault = isDefault
|
storage.IsDefault = isDefault
|
||||||
storage.IsShared = isShared
|
storage.IsShared = isShared
|
||||||
@ -113,4 +128,81 @@ func (s *StorageService) Delete(ctx context.Context, id string) error {
|
|||||||
// List 列出所有存储后端(管理员用)
|
// List 列出所有存储后端(管理员用)
|
||||||
func (s *StorageService) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
func (s *StorageService) List(ctx context.Context) ([]*entity.StorageBackend, error) {
|
||||||
return s.storageRepo.List(ctx)
|
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
|
||||||
}
|
}
|
||||||
@ -26,19 +26,31 @@ func NewWorkspaceService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create 创建工作空间
|
// Create 创建工作空间(支持 cluster_ids 和初始配额)
|
||||||
func (s *WorkspaceService) Create(ctx context.Context, name, description, createdBy string) (*entity.Workspace, error) {
|
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)
|
existing, _ := s.workspaceRepo.GetByName(ctx, name)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
return nil, entity.ErrWorkspaceExists
|
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 {
|
if err := s.workspaceRepo.Create(ctx, workspace); err != nil {
|
||||||
return nil, err
|
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
|
return workspace, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -142,6 +142,8 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_name ON workspaces(name);
|
|||||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||||
id VARCHAR(36) PRIMARY KEY,
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
workspace_id VARCHAR(36),
|
workspace_id VARCHAR(36),
|
||||||
|
owner_id VARCHAR(36),
|
||||||
|
cluster_id VARCHAR(36),
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
type VARCHAR(50) NOT NULL,
|
type VARCHAR(50) NOT NULL,
|
||||||
config JSONB 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_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 表 =====
|
-- ===== Chart References 表 =====
|
||||||
CREATE TABLE IF NOT EXISTS 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,
|
is_default BOOLEAN DEFAULT FALSE,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_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);
|
CREATE INDEX IF NOT EXISTS idx_values_template_chart ON values_templates(chart_reference_id);
|
||||||
|
|||||||
15
backend/scripts/migrations/20250418_add_cluster_storage.sql
Normal file
15
backend/scripts/migrations/20250418_add_cluster_storage.sql
Normal 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.';
|
||||||
@ -27,8 +27,8 @@ export default function UsersManagementPage() {
|
|||||||
adminApi.listUsers(),
|
adminApi.listUsers(),
|
||||||
workspaceApi.list(),
|
workspaceApi.list(),
|
||||||
]);
|
]);
|
||||||
setUsers(usersRes.data.users || []);
|
setUsers(usersRes.data.data?.users || usersRes.data.users || []);
|
||||||
setWorkspaces(workspacesRes.data.workspaces || []);
|
setWorkspaces(workspacesRes.data.data?.workspaces || workspacesRes.data.workspaces || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch data:', error);
|
console.error('Failed to fetch data:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import { workspaceApi } from '@/lib/api';
|
import { workspaceApi, clusterApi } from '@/lib/api';
|
||||||
import type { WorkspaceDTO, QuotaDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types';
|
import type { WorkspaceDTO, QuotaDTO, ClusterDTO, CreateWorkspaceRequest, SetQuotasRequest } from '@/lib/types';
|
||||||
import { FolderKanban, Plus, Trash2, Edit, Settings } from 'lucide-react';
|
import { FolderKanban, Plus, Trash2, Edit, Settings, Server } from 'lucide-react';
|
||||||
|
|
||||||
export default function WorkspacesPage() {
|
export default function WorkspacesPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
|
const [workspaces, setWorkspaces] = useState<WorkspaceDTO[]>([]);
|
||||||
|
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
|
||||||
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
|
const [quotas, setQuotas] = useState<Record<string, QuotaDTO[]>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@ -18,6 +19,10 @@ export default function WorkspacesPage() {
|
|||||||
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
|
const [formData, setFormData] = useState<CreateWorkspaceRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
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>({
|
const [quotaFormData, setQuotaFormData] = useState<SetQuotasRequest>({
|
||||||
cpu: { hard_limit: 10, soft_limit: 8 },
|
cpu: { hard_limit: 10, soft_limit: 8 },
|
||||||
@ -28,7 +33,7 @@ export default function WorkspacesPage() {
|
|||||||
const fetchWorkspaces = async () => {
|
const fetchWorkspaces = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await workspaceApi.list();
|
const response = await workspaceApi.list();
|
||||||
setWorkspaces(response.data.workspaces || []);
|
setWorkspaces(response.data.data?.workspaces || response.data.workspaces || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch workspaces:', error);
|
console.error('Failed to fetch workspaces:', error);
|
||||||
} finally {
|
} 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) => {
|
const fetchQuotas = async (workspaceId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await workspaceApi.getQuotas(workspaceId);
|
const response = await workspaceApi.getQuotas(workspaceId);
|
||||||
@ -47,6 +62,7 @@ export default function WorkspacesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWorkspaces();
|
fetchWorkspaces();
|
||||||
|
fetchClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -60,14 +76,23 @@ export default function WorkspacesPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
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) {
|
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 {
|
} else {
|
||||||
await workspaceApi.create(formData);
|
await workspaceApi.create(request);
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingWorkspace(null);
|
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();
|
fetchWorkspaces();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save workspace:', error);
|
console.error('Failed to save workspace:', error);
|
||||||
@ -77,7 +102,14 @@ export default function WorkspacesPage() {
|
|||||||
|
|
||||||
const handleEdit = (workspace: WorkspaceDTO) => {
|
const handleEdit = (workspace: WorkspaceDTO) => {
|
||||||
setEditingWorkspace(workspace);
|
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);
|
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') {
|
if (user?.role !== 'admin') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[var(--foreground)]">Workspaces</h1>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
setEditingWorkspace(null);
|
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"
|
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 */}
|
{/* Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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-md border border-[var(--border)]">
|
<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">
|
<h2 className="text-lg font-semibold text-[var(--foreground)] mb-4">
|
||||||
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
|
{editingWorkspace ? 'Edit Workspace' : 'Add Workspace'}
|
||||||
</h2>
|
</h2>
|
||||||
@ -165,9 +207,80 @@ export default function WorkspacesPage() {
|
|||||||
value={formData.description || ''}
|
value={formData.description || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="input"
|
className="input"
|
||||||
rows={3}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -376,6 +489,23 @@ export default function WorkspacesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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 Display */}
|
||||||
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
|
{quotas[workspace.id] && quotas[workspace.id].length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-[var(--border)]">
|
<div className="mt-4 pt-4 border-t border-[var(--border)]">
|
||||||
|
|||||||
@ -23,7 +23,12 @@ export default function ChartReferencesPage() {
|
|||||||
const fetchChartRefs = async () => {
|
const fetchChartRefs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await chartReferenceApi.list();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch chart references:', error);
|
console.error('Failed to fetch chart references:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -34,7 +39,12 @@ export default function ChartReferencesPage() {
|
|||||||
const fetchRegistries = async () => {
|
const fetchRegistries = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await registryApi.list();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch registries:', error);
|
console.error('Failed to fetch registries:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,12 +19,12 @@ interface RegistryDTO {
|
|||||||
interface CreateInstanceRequest {
|
interface CreateInstanceRequest {
|
||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
registryId: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
chart: string;
|
chart: string;
|
||||||
version: string;
|
version: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
values_yaml?: string;
|
valuesYaml?: string;
|
||||||
registry_id?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClusterDTO {
|
interface ClusterDTO {
|
||||||
@ -68,6 +68,7 @@ export default function ChartsPage() {
|
|||||||
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
|
const [selectedArtifact, setSelectedArtifact] = useState<Artifact | null>(null);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
const [deployError, setDeployError] = useState<string | null>(null);
|
const [deployError, setDeployError] = useState<string | null>(null);
|
||||||
|
const [valuesYamlError, setValuesYamlError] = useState<string | null>(null);
|
||||||
const [deployForm, setDeployForm] = useState({
|
const [deployForm, setDeployForm] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
namespace: 'default',
|
namespace: 'default',
|
||||||
@ -136,21 +137,30 @@ export default function ChartsPage() {
|
|||||||
const handleStorageChange = (storageId: string) => {
|
const handleStorageChange = (storageId: string) => {
|
||||||
const storage = storages.find(s => s.id === storageId);
|
const storage = storages.find(s => s.id === storageId);
|
||||||
if (storage) {
|
if (storage) {
|
||||||
setDeployForm(prev => ({ ...prev, selectedStorageId: storageId }));
|
// Merge storage config into values using proper persistence format
|
||||||
// Merge storage config into values (simple merge for NFS)
|
let storageConfig = '';
|
||||||
try {
|
if (storage.type === 'nfs') {
|
||||||
const storageConfig = `persistence:
|
// 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
|
enabled: true
|
||||||
storageClass: "${storage.type}"
|
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
|
// Filter to only show chart type artifacts
|
||||||
const allArtifacts = Array.isArray(response.data) ? response.data : [];
|
const allArtifacts = Array.isArray(response.data) ? response.data : [];
|
||||||
const chartArtifacts = allArtifacts.filter((a: Artifact) => a.type === 'chart');
|
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);
|
setArtifacts(chartArtifacts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch artifacts:', error);
|
console.error('Failed to fetch artifacts:', error);
|
||||||
@ -226,9 +247,9 @@ export default function ChartsPage() {
|
|||||||
repository: selectedRepo!,
|
repository: selectedRepo!,
|
||||||
chart: selectedRepo!.split('/').pop() || selectedRepo!,
|
chart: selectedRepo!.split('/').pop() || selectedRepo!,
|
||||||
version: selectedArtifact.tag,
|
version: selectedArtifact.tag,
|
||||||
registry_id: selectedRegistry?.id,
|
registryId: selectedRegistry?.id || '',
|
||||||
description: deployForm.description,
|
description: deployForm.description,
|
||||||
values_yaml: deployForm.valuesYaml || undefined,
|
valuesYaml: deployForm.valuesYaml || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
await instanceApi.create(deployForm.clusterId, request);
|
await instanceApi.create(deployForm.clusterId, request);
|
||||||
@ -260,7 +281,7 @@ export default function ChartsPage() {
|
|||||||
const openDeployModal = (artifact: Artifact) => {
|
const openDeployModal = (artifact: Artifact) => {
|
||||||
setSelectedArtifact(artifact);
|
setSelectedArtifact(artifact);
|
||||||
setDeployForm({
|
setDeployForm({
|
||||||
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
|
name: artifact.repositoryName.split('/').pop()!.toLowerCase(),
|
||||||
namespace: 'default',
|
namespace: 'default',
|
||||||
clusterId: clusters[0]?.id || '',
|
clusterId: clusters[0]?.id || '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -459,7 +480,7 @@ export default function ChartsPage() {
|
|||||||
{/* Deploy Modal */}
|
{/* Deploy Modal */}
|
||||||
{showDeployModal && selectedArtifact && (
|
{showDeployModal && selectedArtifact && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
|
<h2 className="text-xl font-bold text-[var(--foreground)]">Deploy Chart</h2>
|
||||||
<button
|
<button
|
||||||
@ -586,13 +607,42 @@ export default function ChartsPage() {
|
|||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={deployForm.valuesYaml}
|
value={deployForm.valuesYaml}
|
||||||
onChange={(e) => setDeployForm({ ...deployForm, valuesYaml: e.target.value })}
|
onChange={(e) => {
|
||||||
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)] font-mono text-sm"
|
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}
|
rows={4}
|
||||||
placeholder="# Optional: Override chart values replicaCount: 2 image: tag: latest"
|
placeholder="# Optional: Override chart values replicaCount: 2 image: tag: latest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{deployError && (
|
||||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
|
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-red-500 text-sm">
|
||||||
{deployError}
|
{deployError}
|
||||||
@ -613,7 +663,7 @@ export default function ChartsPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDeploy}
|
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"
|
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" />}
|
{isDeploying && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||||
|
|||||||
250
frontend/src/app/instances/page.tsx
Normal file
250
frontend/src/app/instances/page.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,14 +3,13 @@
|
|||||||
import { useState, FormEvent, useEffect } from 'react';
|
import { useState, FormEvent, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import { Shield, Loader2, CheckCircle } from 'lucide-react';
|
import { Shield, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [loginSuccess, setLoginSuccess] = useState(false);
|
|
||||||
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
|
const { login, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@ -37,20 +36,16 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login({ username, password });
|
await login({ username, password });
|
||||||
setLoginSuccess(true);
|
// Redirect immediately after successful login
|
||||||
// Small delay to show success state, then redirect
|
window.location.href = '/';
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/');
|
|
||||||
}, 500);
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
setIsLoading(false);
|
||||||
if (typeof err === 'object' && err !== null && 'response' in err) {
|
if (typeof err === 'object' && err !== null && 'response' in err) {
|
||||||
const axiosErr = err as { response?: { data?: { message?: string } } };
|
const axiosErr = err as { response?: { data?: { message?: string } } };
|
||||||
setError(axiosErr.response?.data?.message || 'Login failed');
|
setError(axiosErr.response?.data?.message || 'Login failed');
|
||||||
} else {
|
} else {
|
||||||
setError('Login failed. Please check your credentials.');
|
setError('Login failed. Please check your credentials.');
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,13 +61,6 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<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 && (
|
{error && (
|
||||||
<div className="p-3 rounded-md bg-[rgba(239,68,68,0.1)] border border-[rgba(239,68,68,0.3)]">
|
<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>
|
<p className="text-sm text-[#ef4444]">{error}</p>
|
||||||
|
|||||||
@ -2,24 +2,36 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
import { useAuth } from '@/lib/auth-context';
|
import { useAuth } from '@/lib/auth-context';
|
||||||
import { monitoringApi, clusterApi } from '@/lib/api';
|
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 {
|
interface DashboardStats {
|
||||||
totalClusters: number;
|
totalClusters: number;
|
||||||
healthyClusters: number;
|
healthyClusters: number;
|
||||||
|
warningClusters: number;
|
||||||
|
errorClusters: number;
|
||||||
totalInstances: number;
|
totalInstances: number;
|
||||||
runningInstances: number;
|
runningInstances: number;
|
||||||
totalNodes: number;
|
totalNodes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ClusterStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user, isLoading, isAuthenticated } = useAuth();
|
const { user, isLoading, isAuthenticated } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
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 [isLoadingData, setIsLoadingData] = useState(true);
|
||||||
|
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Redirect to login if not authenticated
|
// Redirect to login if not authenticated
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -28,8 +40,31 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, router]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
setFetchError(null);
|
||||||
try {
|
try {
|
||||||
const [summaryRes, clustersRes] = await Promise.all([
|
const [summaryRes, clustersRes] = await Promise.all([
|
||||||
monitoringApi.getSummary().catch(() => null),
|
monitoringApi.getSummary().catch(() => null),
|
||||||
@ -40,21 +75,35 @@ export default function DashboardPage() {
|
|||||||
setStats({
|
setStats({
|
||||||
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
|
totalClusters: summaryRes.data.totalClusters ?? summaryRes.data.total_clusters ?? 0,
|
||||||
healthyClusters: summaryRes.data.healthyClusters ?? summaryRes.data.healthy_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,
|
totalInstances: summaryRes.data.totalInstances ?? summaryRes.data.total_instances ?? 0,
|
||||||
runningInstances: summaryRes.data.runningInstances ?? summaryRes.data.running_instances ?? 0,
|
runningInstances: summaryRes.data.runningInstances ?? summaryRes.data.running_instances ?? 0,
|
||||||
totalNodes: summaryRes.data.totalNodes ?? summaryRes.data.total_nodes ?? 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;
|
const clustersData = clustersRes?.data;
|
||||||
|
let clusterList: Array<{ id: string; name: string; host: string }> = [];
|
||||||
if (Array.isArray(clustersData)) {
|
if (Array.isArray(clustersData)) {
|
||||||
setClusters(clustersData);
|
clusterList = clustersData;
|
||||||
} else if (clustersData?.clusters) {
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch dashboard data:', error);
|
console.error('Failed to fetch dashboard data:', error);
|
||||||
|
setFetchError('Failed to load dashboard data. Please check your connection.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoadingData(false);
|
setIsLoadingData(false);
|
||||||
}
|
}
|
||||||
@ -65,6 +114,24 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [isLoading]);
|
}, [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) {
|
if (isLoading || isLoadingData) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
@ -83,8 +150,18 @@ export default function DashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* 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
|
<StatCard
|
||||||
title="Total Clusters"
|
title="Total Clusters"
|
||||||
value={stats?.totalClusters ?? 0}
|
value={stats?.totalClusters ?? 0}
|
||||||
@ -92,30 +169,47 @@ export default function DashboardPage() {
|
|||||||
color="blue"
|
color="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Healthy Clusters"
|
title="Healthy"
|
||||||
value={stats?.healthyClusters ?? 0}
|
value={stats?.healthyClusters ?? 0}
|
||||||
icon={Activity}
|
icon={CheckCircle}
|
||||||
color="green"
|
color="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="Total Instances"
|
title="Warning"
|
||||||
value={stats?.totalInstances ?? 0}
|
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}
|
icon={Container}
|
||||||
color="purple"
|
color="purple"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
title="Running Instances"
|
|
||||||
value={stats?.runningInstances ?? 0}
|
|
||||||
icon={Activity}
|
|
||||||
color="green"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clusters List */}
|
{/* Clusters List */}
|
||||||
<div className="card">
|
<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 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{clusters.map((cluster) => (
|
{clusters.map((cluster) => (
|
||||||
@ -124,13 +218,16 @@ export default function DashboardPage() {
|
|||||||
className="flex items-center justify-between p-3 rounded-lg bg-[var(--secondary)]"
|
className="flex items-center justify-between p-3 rounded-lg bg-[var(--secondary)]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Server className="w-5 h-5 text-[var(--primary)]" />
|
{getStatusIcon(cluster.status)}
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
|
<p className="font-medium text-[var(--foreground)]">{cluster.name}</p>
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">{cluster.host}</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>
|
</div>
|
||||||
<div className="badge badge-success">Active</div>
|
{getStatusBadge(cluster.status)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -149,13 +246,15 @@ function StatCard({
|
|||||||
title: string;
|
title: string;
|
||||||
value: number;
|
value: number;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
color: 'blue' | 'green' | 'purple' | 'orange';
|
color: 'blue' | 'green' | 'purple' | 'orange' | 'yellow' | 'red';
|
||||||
}) {
|
}) {
|
||||||
const colorClasses = {
|
const colorClasses = {
|
||||||
blue: 'text-blue-500 bg-blue-500/10',
|
blue: 'text-blue-500 bg-blue-500/10',
|
||||||
green: 'text-green-500 bg-green-500/10',
|
green: 'text-green-500 bg-green-500/10',
|
||||||
purple: 'text-purple-500 bg-purple-500/10',
|
purple: 'text-purple-500 bg-purple-500/10',
|
||||||
orange: 'text-orange-500 bg-orange-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 (
|
return (
|
||||||
|
|||||||
@ -1,28 +1,40 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { storageApi } from '@/lib/api';
|
import { storageApi, clusterApi } from '@/lib/api';
|
||||||
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest } from '@/lib/types';
|
import type { StorageDTO, CreateStorageRequest, UpdateStorageRequest, ClusterDTO, StorageResolutionDTO } from '@/lib/types';
|
||||||
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2 } from 'lucide-react';
|
import { HardDrive, Plus, Trash2, Edit, Server, Folder, Loader2, Layers, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
export default function StoragePage() {
|
export default function StoragePage() {
|
||||||
const [storages, setStorages] = useState<StorageDTO[]>([]);
|
const [storages, setStorages] = useState<StorageDTO[]>([]);
|
||||||
|
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
|
const [editingStorage, setEditingStorage] = useState<StorageDTO | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
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>({
|
const [formData, setFormData] = useState<CreateStorageRequest>({
|
||||||
name: '',
|
name: '',
|
||||||
type: 'nfs',
|
type: 'nfs',
|
||||||
description: '',
|
description: '',
|
||||||
is_default: false,
|
is_default: false,
|
||||||
is_shared: false,
|
is_shared: false,
|
||||||
|
cluster_id: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchStorages = async () => {
|
const fetchStorages = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await storageApi.list();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch storages:', error);
|
console.error('Failed to fetch storages:', error);
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
fetchStorages();
|
fetchStorages();
|
||||||
|
fetchClusters();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedClusterId) {
|
||||||
|
resolveStorage(selectedClusterId);
|
||||||
|
} else {
|
||||||
|
setResolvedStorage(null);
|
||||||
|
}
|
||||||
|
}, [selectedClusterId]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
@ -45,7 +97,7 @@ export default function StoragePage() {
|
|||||||
}
|
}
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
setEditingStorage(null);
|
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();
|
fetchStorages();
|
||||||
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
|
alert(editingStorage ? 'Storage backend updated successfully!' : 'Storage backend created successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -64,6 +116,7 @@ export default function StoragePage() {
|
|||||||
description: storage.description || '',
|
description: storage.description || '',
|
||||||
is_default: storage.is_default,
|
is_default: storage.is_default,
|
||||||
is_shared: storage.is_shared,
|
is_shared: storage.is_shared,
|
||||||
|
cluster_id: (storage as any).workspace_id ? '' : (storage as any).cluster_id || '',
|
||||||
nfs: storage.config.nfs,
|
nfs: storage.config.nfs,
|
||||||
pv: storage.config.pv,
|
pv: storage.config.pv,
|
||||||
hostPath: storage.config.hostPath,
|
hostPath: storage.config.hostPath,
|
||||||
@ -157,7 +210,7 @@ export default function StoragePage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
setEditingStorage(null);
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</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> > <span className="font-medium text-[var(--foreground)]">Cluster Default</span> > <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 */}
|
{/* Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<label className="label">Description</label>
|
<label className="label">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -353,6 +568,9 @@ export default function StoragePage() {
|
|||||||
{storage.is_shared && (
|
{storage.is_shared && (
|
||||||
<span className="badge badge-success">Shared</span>
|
<span className="badge badge-success">Shared</span>
|
||||||
)}
|
)}
|
||||||
|
{(storage as any).cluster_id && (
|
||||||
|
<span className="badge">Cluster</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{renderConfig(storage)}
|
{renderConfig(storage)}
|
||||||
{storage.description && (
|
{storage.description && (
|
||||||
|
|||||||
@ -27,7 +27,12 @@ export default function TemplatesPage() {
|
|||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await valuesTemplateApi.list();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch templates:', error);
|
console.error('Failed to fetch templates:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -38,7 +43,12 @@ export default function TemplatesPage() {
|
|||||||
const fetchChartRefs = async () => {
|
const fetchChartRefs = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await chartReferenceApi.list();
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch chart references:', error);
|
console.error('Failed to fetch chart references:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
Package,
|
Package,
|
||||||
FileText,
|
FileText,
|
||||||
|
Rocket,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { logout } from '@/lib/api';
|
import { logout } from '@/lib/api';
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ const navigation = [
|
|||||||
{ name: 'Clusters', href: '/clusters', icon: Server },
|
{ name: 'Clusters', href: '/clusters', icon: Server },
|
||||||
{ name: 'Registries', href: '/registries', icon: Database },
|
{ name: 'Registries', href: '/registries', icon: Database },
|
||||||
{ name: 'Charts', href: '/charts', icon: Package },
|
{ name: 'Charts', href: '/charts', icon: Package },
|
||||||
|
{ name: 'Deployments', href: '/instances', icon: Rocket },
|
||||||
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
{ name: 'Storage', href: '/storage', icon: HardDrive },
|
||||||
{ name: 'Chart References', href: '/chart-references', icon: FileText },
|
{ name: 'Chart References', href: '/chart-references', icon: FileText },
|
||||||
{ name: 'Values Templates', href: '/templates', icon: FileText },
|
{ name: 'Values Templates', href: '/templates', icon: FileText },
|
||||||
|
|||||||
@ -266,6 +266,9 @@ export const storageApi = {
|
|||||||
|
|
||||||
delete: (storageId: string) =>
|
delete: (storageId: string) =>
|
||||||
api.delete(`/storage-backends/${storageId}`),
|
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
|
// Chart Reference API
|
||||||
|
|||||||
@ -41,16 +41,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const login = async (credentials: LoginRequest) => {
|
const login = async (credentials: LoginRequest) => {
|
||||||
const response = await authApi.login(credentials);
|
const response = await authApi.login(credentials);
|
||||||
// API returns camelCase: accessToken, refreshToken
|
// API returns wrapped: { message: "", data: { accessToken, refreshToken } }
|
||||||
const access_token = response.data.accessToken;
|
const access_token = response.data.data?.accessToken || response.data.accessToken;
|
||||||
const refresh_token = response.data.refreshToken;
|
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('access_token', access_token);
|
||||||
localStorage.setItem('refresh_token', refresh_token);
|
localStorage.setItem('refresh_token', refresh_token);
|
||||||
|
|
||||||
// Fetch user info - API returns { message: "", data: { user: {...} } }
|
// Fetch user info - API returns { message: "", data: { user: {...} } }
|
||||||
const userResponse = await authApi.getCurrentUser();
|
const userResponse = await authApi.getCurrentUser();
|
||||||
const user = userResponse.data.data.user;
|
const user = userResponse.data.data?.user || userResponse.data.user;
|
||||||
setUser(user);
|
setUser(user);
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
localStorage.setItem('user', JSON.stringify(user));
|
||||||
};
|
};
|
||||||
|
|||||||
@ -51,6 +51,8 @@ export interface UserListResponse {
|
|||||||
export interface WorkspaceDTO {
|
export interface WorkspaceDTO {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
cluster_ids?: string[];
|
||||||
|
quotas?: QuotaDTO[];
|
||||||
description?: string;
|
description?: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@ -79,11 +81,16 @@ export interface WorkspaceListResponse {
|
|||||||
export interface CreateWorkspaceRequest {
|
export interface CreateWorkspaceRequest {
|
||||||
name: string;
|
name: string;
|
||||||
description?: 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 {
|
export interface UpdateWorkspaceRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
cluster_ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetQuotasRequest {
|
export interface SetQuotasRequest {
|
||||||
@ -214,6 +221,7 @@ export interface InstanceDTO {
|
|||||||
export interface CreateInstanceRequest {
|
export interface CreateInstanceRequest {
|
||||||
name: string;
|
name: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
registryId: string;
|
||||||
repository: string;
|
repository: string;
|
||||||
chart: string;
|
chart: string;
|
||||||
version: string;
|
version: string;
|
||||||
@ -323,6 +331,7 @@ export interface CreateStorageRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
is_shared?: boolean;
|
is_shared?: boolean;
|
||||||
|
cluster_id?: string;
|
||||||
nfs?: NFSConfig;
|
nfs?: NFSConfig;
|
||||||
pv?: PVConfig;
|
pv?: PVConfig;
|
||||||
hostPath?: HostPathConfig;
|
hostPath?: HostPathConfig;
|
||||||
@ -334,11 +343,19 @@ export interface UpdateStorageRequest {
|
|||||||
description?: string;
|
description?: string;
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
is_shared?: boolean;
|
is_shared?: boolean;
|
||||||
|
cluster_id?: string;
|
||||||
nfs?: NFSConfig;
|
nfs?: NFSConfig;
|
||||||
pv?: PVConfig;
|
pv?: PVConfig;
|
||||||
hostPath?: HostPathConfig;
|
hostPath?: HostPathConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StorageResolutionDTO {
|
||||||
|
storage?: StorageDTO;
|
||||||
|
values_yaml?: string;
|
||||||
|
source?: string; // workspace, cluster, shared
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Chart Reference Types
|
// Chart Reference Types
|
||||||
export interface ChartReferenceDTO {
|
export interface ChartReferenceDTO {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
122
tasks/lessons.md
122
tasks/lessons.md
@ -24,4 +24,124 @@
|
|||||||
**根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步
|
**根因**: 代码中的 InitSchema 与实际 init-db.sql 不同步
|
||||||
**影响**: GetByID/GetByName 查询时字段数不匹配会报错
|
**影响**: GetByID/GetByName 查询时字段数不匹配会报错
|
||||||
**Fix**: 修复 GetByID/GetByName 的查询和 Scan,使用实际的 DB schema
|
**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 在同一 package,main.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 异步执行 Helm,storage 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 TABLE(PostgreSQL 自动截断长约束名到 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` 条件不满足,**静默跳过** merge,Helm 收到空 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...`)直接加密存储不同
|
||||||
113
tasks/todo.md
113
tasks/todo.md
@ -8,32 +8,28 @@
|
|||||||
- ✅ Phase 4: E2E 端到端验证
|
- ✅ Phase 4: E2E 端到端验证
|
||||||
- ✅ Bug Fix: frontend version → backend req.Tag 字段映射
|
- ✅ Bug Fix: frontend version → backend req.Tag 字段映射
|
||||||
- ✅ Bug Fix: registry 解密失败 graceful fallback
|
- ✅ Bug Fix: registry 解密失败 graceful fallback
|
||||||
- 🔄 Phase 5: Values Template 版本管理 (P2)
|
- ✅ Bug Fix: frontend registryId 字段缺失导致部署 API 失败
|
||||||
- 🔄 Phase 6: Storage 分层配置 (P2)
|
- ✅ 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 ✓
|
- Admin 创建 workspace → 创建 user ✓
|
||||||
- User 登录 → 浏览 Charts → 部署成功 → status=deployed ✓
|
- User 登录 → 浏览 Charts → 部署成功 → status=deployed ✓
|
||||||
- Chart 从 Harbor OCI 下载到 /tmp/charts/ ✓
|
- Chart 从 Harbor OCI 下载到 /tmp/charts/ ✓
|
||||||
- Helm release 部署到 K8s 集群 ✓
|
- 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_dto.go - 添加 Version 字段,Normalize() 兼容 version/tag
|
||||||
- [x] Backend: instance_handler.go - 添加 version 空值校验
|
- [x] Backend: instance_handler.go - 添加 version 空值校验
|
||||||
@ -41,5 +37,84 @@
|
|||||||
- [x] Backend: registry_repository.go - 修复 GetByID/GetByName schema 字段不匹配
|
- [x] Backend: registry_repository.go - 修复 GetByID/GetByName schema 字段不匹配
|
||||||
- [x] Backend: registry_repository.go - 解密失败时返回空密码而非错误
|
- [x] Backend: registry_repository.go - 解密失败时返回空密码而非错误
|
||||||
- [x] Frontend: charts/page.tsx - 添加 Template 和 Storage 下拉选择器
|
- [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] 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}` ✓
|
||||||
Reference in New Issue
Block a user