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:
River
2026-04-14 15:21:56 +08:00
parent 68b7ca6497
commit d5398ee671
14 changed files with 62 additions and 26 deletions
+5
View File
@@ -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 ./
+2
View File
@@ -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"`
+22 -7
View File
@@ -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":
+1 -1
View File
@@ -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"`
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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)),
},
}
+2 -2
View File
@@ -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)
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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>