feat(instance): support fractional CPU cores in instance creation
- Change cpu_cores from int to float64 in backend models, handlers, services - Update frontend CreateInstancePage to accept decimal CPU values (step 0.1) - Add DB migration 009_cpu_cores_decimal.sql for DECIMAL(10,2) columns - Update deployment manifests (k8s, k3s, incluster) schema definitions - Add GOFLAGS/GOPROXY/GOSUMDB build args to Dockerfile so that --build-arg values passed by build scripts take effect during go mod download
This commit is contained in:
@@ -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 ./
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -494,10 +494,11 @@ const CreateInstancePage: React.FC = () => {
|
||||
<input
|
||||
type="number"
|
||||
id="cpu"
|
||||
min={1}
|
||||
min={0.1}
|
||||
max={32}
|
||||
step={0.1}
|
||||
value={formData.cpu_cores}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user