diff --git a/Dockerfile b/Dockerfile index 2173302..37c9f5c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ FROM golang:1.26.1-alpine AS backend-builder WORKDIR /app/backend +ARG GOPROXY +ARG GOSUMDB +ARG GOFLAGS +ENV GOFLAGS=${GOFLAGS} + RUN apk add --no-cache git COPY backend/go.mod backend/go.sum ./ diff --git a/backend/deployments/docker/Dockerfile b/backend/deployments/docker/Dockerfile index 985d755..f572a95 100644 --- a/backend/deployments/docker/Dockerfile +++ b/backend/deployments/docker/Dockerfile @@ -1,5 +1,7 @@ # Build stage FROM golang:1.21-alpine AS builder +ENV GOPROXY=https://goproxy.cn,direct +ENV GOSUMDB=sum.golang.google.cn WORKDIR /app diff --git a/backend/deployments/k8s/clawreef-incluster.yaml b/backend/deployments/k8s/clawreef-incluster.yaml index 45a967d..85678a5 100644 --- a/backend/deployments/k8s/clawreef-incluster.yaml +++ b/backend/deployments/k8s/clawreef-incluster.yaml @@ -45,7 +45,7 @@ data: description TEXT, type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu', status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating', - cpu_cores INT NOT NULL, + cpu_cores DECIMAL(10,2) NOT NULL, memory_gb INT NOT NULL, disk_gb INT NOT NULL, gpu_enabled BOOLEAN DEFAULT FALSE, @@ -121,7 +121,7 @@ data: id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL UNIQUE, max_instances INT DEFAULT 10, - max_cpu_cores INT DEFAULT 40, + max_cpu_cores DECIMAL(10,2) DEFAULT 40, max_memory_gb INT DEFAULT 100, max_storage_gb INT DEFAULT 500, max_gpu_count INT DEFAULT 2, diff --git a/backend/internal/db/migrations/009_cpu_cores_decimal.sql b/backend/internal/db/migrations/009_cpu_cores_decimal.sql new file mode 100644 index 0000000..6a09d82 --- /dev/null +++ b/backend/internal/db/migrations/009_cpu_cores_decimal.sql @@ -0,0 +1,5 @@ +-- Allow fractional CPU cores for instances and quotas +-- Created: 2026-04-14 + +ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL; +ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40; diff --git a/backend/internal/handlers/instance_handler.go b/backend/internal/handlers/instance_handler.go index 34ae5bd..ec7f272 100644 --- a/backend/internal/handlers/instance_handler.go +++ b/backend/internal/handlers/instance_handler.go @@ -65,7 +65,7 @@ type CreateInstanceRequest struct { Name string `json:"name" binding:"required,min=3,max=50"` Description *string `json:"description,omitempty"` Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"` - CPUCores int `json:"cpu_cores" binding:"required,min=1,max=32"` + CPUCores float64 `json:"cpu_cores" binding:"required,min=0.1,max=32"` MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"` DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"` GPUEnabled bool `json:"gpu_enabled"` diff --git a/backend/internal/handlers/user_handler.go b/backend/internal/handlers/user_handler.go index ba29af7..50e3373 100644 --- a/backend/internal/handlers/user_handler.go +++ b/backend/internal/handlers/user_handler.go @@ -48,9 +48,9 @@ type UpdateRoleRequest struct { // UpdateQuotaRequest represents an update quota request type UpdateQuotaRequest struct { - MaxInstances int `json:"max_instances" binding:"min=0"` - MaxCPUCores int `json:"max_cpu_cores" binding:"min=0"` - MaxMemoryGB int `json:"max_memory_gb" binding:"min=0"` + MaxInstances int `json:"max_instances" binding:"min=0"` + MaxCPUCores float64 `json:"max_cpu_cores" binding:"min=0"` + MaxMemoryGB int `json:"max_memory_gb" binding:"min=0"` MaxStorageGB int `json:"max_storage_gb" binding:"min=0"` MaxGPUCount int `json:"max_gpu_count" binding:"min=0"` } @@ -73,9 +73,9 @@ type importedUserCredential struct { Username string `json:"username"` Email string `json:"email"` Role string `json:"role"` - MaxInstances int `json:"max_instances"` - MaxCPUCores int `json:"max_cpu_cores"` - MaxMemoryGB int `json:"max_memory_gb"` + MaxInstances int `json:"max_instances"` + MaxCPUCores float64 `json:"max_cpu_cores"` + MaxMemoryGB int `json:"max_memory_gb"` MaxStorageGB int `json:"max_storage_gb"` MaxGPUCount int `json:"max_gpu_count"` InitialPassword string `json:"initial_password"` @@ -201,7 +201,7 @@ func (h *UserHandler) ImportUsers(c *gin.Context) { results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr}) continue } - maxCPUCores, parseErr := parseImportInt(fields, headerMap, "maxcpucores", true) + maxCPUCores, parseErr := parseImportFloat(fields, headerMap, "maxcpucores", true) if parseErr != "" { results = append(results, importUserResult{Line: lineNumber, Username: username, Error: parseErr}) continue @@ -358,6 +358,21 @@ func parseImportInt(fields []string, headerMap map[string]int, key string, requi return parsed, "" } +func parseImportFloat(fields []string, headerMap map[string]int, key string, required bool) (float64, string) { + value := importFieldValue(fields, headerMap, key) + if value == "" { + if required { + return 0, fmt.Sprintf("%s is required", headerLabel(key)) + } + return 0, "" + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || parsed < 0 { + return 0, fmt.Sprintf("%s must be a non-negative number", headerLabel(key)) + } + return parsed, "" +} + func headerLabel(key string) string { switch key { case "username": diff --git a/backend/internal/models/instance.go b/backend/internal/models/instance.go index fdcce11..0c233c5 100644 --- a/backend/internal/models/instance.go +++ b/backend/internal/models/instance.go @@ -12,7 +12,7 @@ type Instance struct { Description *string `db:"description" json:"description,omitempty"` Type string `db:"type" json:"type"` Status string `db:"status" json:"status"` - CPUCores int `db:"cpu_cores" json:"cpu_cores"` + CPUCores float64 `db:"cpu_cores" json:"cpu_cores"` MemoryGB int `db:"memory_gb" json:"memory_gb"` DiskGB int `db:"disk_gb" json:"disk_gb"` GPUEnabled bool `db:"gpu_enabled" json:"gpu_enabled"` diff --git a/backend/internal/models/user_quota.go b/backend/internal/models/user_quota.go index 165c371..1e64594 100644 --- a/backend/internal/models/user_quota.go +++ b/backend/internal/models/user_quota.go @@ -9,7 +9,7 @@ type UserQuota struct { ID int `db:"id,primarykey,autoincrement" json:"id"` UserID int `db:"user_id" json:"user_id"` MaxInstances int `db:"max_instances" json:"max_instances"` - MaxCPUCores int `db:"max_cpu_cores" json:"max_cpu_cores"` + MaxCPUCores float64 `db:"max_cpu_cores" json:"max_cpu_cores"` MaxMemoryGB int `db:"max_memory_gb" json:"max_memory_gb"` MaxStorageGB int `db:"max_storage_gb" json:"max_storage_gb"` MaxGPUCount int `db:"max_gpu_count" json:"max_gpu_count"` diff --git a/backend/internal/services/instance_service.go b/backend/internal/services/instance_service.go index 427eb2b..ae52425 100644 --- a/backend/internal/services/instance_service.go +++ b/backend/internal/services/instance_service.go @@ -36,7 +36,7 @@ type CreateInstanceRequest struct { Name string `json:"name" validate:"required,min=3,max=50"` Description *string `json:"description,omitempty"` Type string `json:"type" validate:"required,oneof=openclaw ubuntu debian centos custom webtop"` - CPUCores int `json:"cpu_cores" validate:"required,min=1,max=32"` + CPUCores float64 `json:"cpu_cores" validate:"required,min=0.1,max=32"` MemoryGB int `json:"memory_gb" validate:"required,min=1,max=128"` DiskGB int `json:"disk_gb" validate:"required,min=10,max=1000"` GPUEnabled bool `json:"gpu_enabled"` @@ -123,7 +123,7 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models return nil, fmt.Errorf("failed to list user instances for quota validation: %w", err) } - currentCPU := 0 + currentCPU := 0.0 currentMemory := 0 currentStorage := 0 currentGPU := 0 @@ -146,7 +146,7 @@ func (s *instanceService) Create(userID int, req CreateInstanceRequest) (*models // Check CPU limit if currentCPU+req.CPUCores > quota.MaxCPUCores { - return nil, fmt.Errorf("CPU cores exceed quota: current %d, requested %d, max %d", currentCPU, req.CPUCores, quota.MaxCPUCores) + return nil, fmt.Errorf("CPU cores exceed quota: current %v, requested %v, max %v", currentCPU, req.CPUCores, quota.MaxCPUCores) } // Check memory limit diff --git a/backend/internal/services/k8s/pod_service.go b/backend/internal/services/k8s/pod_service.go index 240acb1..2d6b77e 100644 --- a/backend/internal/services/k8s/pod_service.go +++ b/backend/internal/services/k8s/pod_service.go @@ -35,7 +35,7 @@ type PodConfig struct { InstanceName string UserID int Type string - CPUCores int + CPUCores float64 MemoryGB int GPUEnabled bool GPUCount int @@ -59,11 +59,11 @@ func (s *PodService) CreatePod(ctx context.Context, config PodConfig) (*corev1.P // Build resource requirements resources := corev1.ResourceRequirements{ Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse(fmt.Sprintf("%d", config.CPUCores)), + corev1.ResourceCPU: resource.MustParse(fmt.Sprintf("%g", config.CPUCores)), corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dGi", config.MemoryGB)), }, Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse(fmt.Sprintf("%d", config.CPUCores)), + corev1.ResourceCPU: resource.MustParse(fmt.Sprintf("%g", config.CPUCores)), corev1.ResourceMemory: resource.MustParse(fmt.Sprintf("%dGi", config.MemoryGB)), }, } diff --git a/backend/internal/services/quota_service.go b/backend/internal/services/quota_service.go index 3b003cf..8f26ffd 100644 --- a/backend/internal/services/quota_service.go +++ b/backend/internal/services/quota_service.go @@ -13,7 +13,7 @@ type QuotaService interface { GetUserQuota(userID int) (*models.UserQuota, error) UpdateUserQuota(userID int, quota *models.UserQuota) error CreateDefaultQuota(userID int) (*models.UserQuota, error) - CheckUserQuota(userID int, requiredCPU, requiredMemory, requiredStorage int) error + CheckUserQuota(userID int, requiredCPU float64, requiredMemory, requiredStorage int) error } // quotaService implements QuotaService @@ -75,7 +75,7 @@ func (s *quotaService) CreateDefaultQuota(userID int) (*models.UserQuota, error) } // CheckUserQuota checks if user has enough quota for new instance -func (s *quotaService) CheckUserQuota(userID int, requiredCPU, requiredMemory, requiredStorage int) error { +func (s *quotaService) CheckUserQuota(userID int, requiredCPU float64, requiredMemory, requiredStorage int) error { quota, err := s.GetUserQuota(userID) if err != nil { return fmt.Errorf("failed to get user quota: %w", err) diff --git a/deployments/k3s/clawmanager.yaml b/deployments/k3s/clawmanager.yaml index 374a02b..b685276 100644 --- a/deployments/k3s/clawmanager.yaml +++ b/deployments/k3s/clawmanager.yaml @@ -45,7 +45,7 @@ data: description TEXT, type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu', status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating', - cpu_cores INT NOT NULL, + cpu_cores DECIMAL(10,2) NOT NULL, memory_gb INT NOT NULL, disk_gb INT NOT NULL, gpu_enabled BOOLEAN DEFAULT FALSE, @@ -121,7 +121,7 @@ data: id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL UNIQUE, max_instances INT DEFAULT 10, - max_cpu_cores INT DEFAULT 40, + max_cpu_cores DECIMAL(10,2) DEFAULT 40, max_memory_gb INT DEFAULT 100, max_storage_gb INT DEFAULT 500, max_gpu_count INT DEFAULT 2, @@ -497,6 +497,10 @@ data: INDEX idx_skill_scan_results_blob (blob_id, scanned_at), INDEX idx_skill_scan_results_risk (risk_level, scanned_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 009_cpu_cores_decimal.sql: | + USE clawmanager; + ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL; + ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40; --- apiVersion: v1 kind: PersistentVolumeClaim diff --git a/deployments/k8s/clawmanager.yaml b/deployments/k8s/clawmanager.yaml index 83559c0..adfaedb 100644 --- a/deployments/k8s/clawmanager.yaml +++ b/deployments/k8s/clawmanager.yaml @@ -49,7 +49,7 @@ data: description TEXT, type ENUM('openclaw', 'ubuntu', 'debian', 'centos', 'custom', 'webtop') DEFAULT 'ubuntu', status ENUM('creating', 'running', 'stopped', 'error', 'deleting') DEFAULT 'creating', - cpu_cores INT NOT NULL, + cpu_cores DECIMAL(10,2) NOT NULL, memory_gb INT NOT NULL, disk_gb INT NOT NULL, gpu_enabled BOOLEAN DEFAULT FALSE, @@ -125,7 +125,7 @@ data: id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL UNIQUE, max_instances INT DEFAULT 10, - max_cpu_cores INT DEFAULT 40, + max_cpu_cores DECIMAL(10,2) DEFAULT 40, max_memory_gb INT DEFAULT 100, max_storage_gb INT DEFAULT 500, max_gpu_count INT DEFAULT 2, @@ -501,6 +501,10 @@ data: INDEX idx_skill_scan_results_blob (blob_id, scanned_at), INDEX idx_skill_scan_results_risk (risk_level, scanned_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + 009_cpu_cores_decimal.sql: | + USE clawmanager; + ALTER TABLE instances MODIFY COLUMN cpu_cores DECIMAL(10,2) NOT NULL; + ALTER TABLE user_quotas MODIFY COLUMN max_cpu_cores DECIMAL(10,2) DEFAULT 40; --- apiVersion: v1 kind: PersistentVolume diff --git a/frontend/src/pages/instances/CreateInstancePage.tsx b/frontend/src/pages/instances/CreateInstancePage.tsx index f55f63e..4cca412 100644 --- a/frontend/src/pages/instances/CreateInstancePage.tsx +++ b/frontend/src/pages/instances/CreateInstancePage.tsx @@ -494,10 +494,11 @@ const CreateInstancePage: React.FC = () => { setFormData({ ...formData, cpu_cores: parseInt(e.target.value) || 1 })} + onChange={(e) => setFormData({ ...formData, cpu_cores: parseFloat(e.target.value) || 0.1 })} className="app-input mt-1 block w-full" />