Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/db/migrations/001_init_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS instances (
os_version VARCHAR(50) NOT NULL,
image_registry VARCHAR(255),
image_tag VARCHAR(100),
environment_overrides_json LONGTEXT,
storage_class VARCHAR(50) DEFAULT 'standard',
mount_path VARCHAR(255) DEFAULT '/data',
pod_name VARCHAR(255),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SET @instance_environment_overrides_column_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'instances'
AND COLUMN_NAME = 'environment_overrides_json'
);
SET @instance_environment_overrides_column_sql = IF(
@instance_environment_overrides_column_exists = 0,
'ALTER TABLE instances ADD COLUMN environment_overrides_json LONGTEXT NULL AFTER image_tag',
'SELECT 1'
);
PREPARE instance_environment_overrides_column_stmt FROM @instance_environment_overrides_column_sql;
EXECUTE instance_environment_overrides_column_stmt;
DEALLOCATE PREPARE instance_environment_overrides_column_stmt;
60 changes: 31 additions & 29 deletions backend/internal/handlers/instance_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,22 @@ type PublishConfigRevisionRequest struct {

// CreateInstanceRequest represents a create instance request
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 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"`
GPUCount int `json:"gpu_count" binding:"min=0,max=4"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version" binding:"required"`
ImageRegistry *string `json:"image_registry,omitempty"`
ImageTag *string `json:"image_tag,omitempty"`
StorageClass string `json:"storage_class"`
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
SkillIDs []int `json:"skill_ids,omitempty"`
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 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"`
GPUCount int `json:"gpu_count" binding:"min=0,max=4"`
OSType string `json:"os_type" binding:"required"`
OSVersion string `json:"os_version" binding:"required"`
ImageRegistry *string `json:"image_registry,omitempty"`
ImageTag *string `json:"image_tag,omitempty"`
EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
StorageClass string `json:"storage_class"`
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
SkillIDs []int `json:"skill_ids,omitempty"`
}

// UpdateInstanceRequest represents an update instance request
Expand Down Expand Up @@ -133,20 +134,21 @@ func (h *InstanceHandler) CreateInstance(c *gin.Context) {
}

createReq := services.CreateInstanceRequest{
Name: req.Name,
Description: req.Description,
Type: req.Type,
CPUCores: req.CPUCores,
MemoryGB: req.MemoryGB,
DiskGB: req.DiskGB,
GPUEnabled: req.GPUEnabled,
GPUCount: req.GPUCount,
OSType: req.OSType,
OSVersion: req.OSVersion,
ImageRegistry: req.ImageRegistry,
ImageTag: req.ImageTag,
StorageClass: req.StorageClass,
OpenClawConfigPlan: req.OpenClawConfigPlan,
Name: req.Name,
Description: req.Description,
Type: req.Type,
CPUCores: req.CPUCores,
MemoryGB: req.MemoryGB,
DiskGB: req.DiskGB,
GPUEnabled: req.GPUEnabled,
GPUCount: req.GPUCount,
OSType: req.OSType,
OSVersion: req.OSVersion,
ImageRegistry: req.ImageRegistry,
ImageTag: req.ImageTag,
EnvironmentOverrides: req.EnvironmentOverrides,
StorageClass: req.StorageClass,
OpenClawConfigPlan: req.OpenClawConfigPlan,
}

instance, err := h.instanceService.Create(userID.(int), createReq)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/models/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type Instance struct {
OSVersion string `db:"os_version" json:"os_version"`
ImageRegistry *string `db:"image_registry" json:"image_registry,omitempty"`
ImageTag *string `db:"image_tag" json:"image_tag,omitempty"`
EnvironmentOverridesJSON *string `db:"environment_overrides_json" json:"-"`
StorageClass string `db:"storage_class" json:"storage_class"`
MountPath string `db:"mount_path" json:"mount_path"`
PodName *string `db:"pod_name" json:"pod_name,omitempty"`
Expand Down
84 changes: 84 additions & 0 deletions backend/internal/services/instance_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package services

import (
"encoding/json"
"fmt"
"regexp"
"strings"

"clawreef/internal/models"
)

var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

func normalizeEnvironmentOverrides(overrides map[string]string) (map[string]string, error) {
if len(overrides) == 0 {
return nil, nil
}

normalized := make(map[string]string, len(overrides))
for rawKey, value := range overrides {
key := strings.TrimSpace(rawKey)
if key == "" {
return nil, fmt.Errorf("environment variable name cannot be empty")
}
if !envNamePattern.MatchString(key) {
return nil, fmt.Errorf("invalid environment variable name: %s", key)
}
if _, exists := normalized[key]; exists {
return nil, fmt.Errorf("duplicate environment variable name: %s", key)
}
normalized[key] = value
}

return normalized, nil
}

func marshalEnvironmentOverrides(overrides map[string]string) (*string, error) {
if len(overrides) == 0 {
return nil, nil
}

raw, err := json.Marshal(overrides)
if err != nil {
return nil, fmt.Errorf("failed to encode environment overrides: %w", err)
}

encoded := string(raw)
return &encoded, nil
}

func parseEnvironmentOverridesJSON(raw *string) (map[string]string, error) {
if raw == nil || strings.TrimSpace(*raw) == "" {
return nil, nil
}

var overrides map[string]string
if err := json.Unmarshal([]byte(strings.TrimSpace(*raw)), &overrides); err != nil {
return nil, fmt.Errorf("failed to decode environment overrides: %w", err)
}

normalized, err := normalizeEnvironmentOverrides(overrides)
if err != nil {
return nil, err
}

return normalized, nil
}

func buildInstancePodEnv(instance *models.Instance, runtimeEnv, gatewayEnv, agentEnv map[string]string) (map[string]string, error) {
if instance == nil {
return nil, fmt.Errorf("instance is required")
}

overrides, err := parseEnvironmentOverridesJSON(instance.EnvironmentOverridesJSON)
if err != nil {
return nil, err
}

resolved := mergeEnvMaps(runtimeEnv, mergeEnvMaps(gatewayEnv, agentEnv))
resolved = withInstanceProxyEnv(instance.Type, instance.ID, resolved)
resolved = mergeEnvMaps(resolved, overrides)

return resolved, nil
}
70 changes: 70 additions & 0 deletions backend/internal/services/instance_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package services

import (
"testing"

"clawreef/internal/models"
)

func TestNormalizeEnvironmentOverrides(t *testing.T) {
overrides, err := normalizeEnvironmentOverrides(map[string]string{
" FOO ": "bar",
"BAR_2": "",
})
if err != nil {
t.Fatalf("normalizeEnvironmentOverrides returned error: %v", err)
}

if overrides["FOO"] != "bar" {
t.Fatalf("expected trimmed key FOO to be preserved")
}
if value, ok := overrides["BAR_2"]; !ok || value != "" {
t.Fatalf("expected empty override value to be preserved")
}
}

func TestNormalizeEnvironmentOverridesRejectsInvalidNames(t *testing.T) {
if _, err := normalizeEnvironmentOverrides(map[string]string{
"1INVALID": "value",
}); err == nil {
t.Fatalf("expected invalid environment variable name to fail validation")
}
}

func TestBuildInstancePodEnvAppliesOverridesAfterDefaults(t *testing.T) {
t.Setenv("CLAWMANAGER_EGRESS_PROXY_URL", "")
t.Setenv("CLAWMANAGER_SYSTEM_NAMESPACE", "")
t.Setenv("K8S_NAMESPACE", "")

raw, err := marshalEnvironmentOverrides(map[string]string{
"SUBFOLDER": "/custom-proxy",
"CUSTOM": "enabled",
})
if err != nil {
t.Fatalf("marshalEnvironmentOverrides returned error: %v", err)
}

instance := &models.Instance{
ID: 42,
Type: "webtop",
EnvironmentOverridesJSON: raw,
}

env, err := buildInstancePodEnv(instance, map[string]string{
"TITLE": "ClawManager Webtop",
"SUBFOLDER": "/",
}, nil, nil)
if err != nil {
t.Fatalf("buildInstancePodEnv returned error: %v", err)
}

if env["SUBFOLDER"] != "/custom-proxy" {
t.Fatalf("expected SUBFOLDER override to win, got %q", env["SUBFOLDER"])
}
if env["CUSTOM"] != "enabled" {
t.Fatalf("expected custom environment variable to be merged")
}
if env["TITLE"] != "ClawManager Webtop" {
t.Fatalf("expected default environment variable to remain available")
}
}
Loading
Loading