Skip to content

Commit d4c3fa1

Browse files
authored
Merge pull request #71 from Iamlovingit/env
feat: improve instance creation environment workflow
2 parents 4a3081e + 366bae1 commit d4c3fa1

File tree

10 files changed

+1797
-609
lines changed

10 files changed

+1797
-609
lines changed

backend/internal/db/migrations/001_init_schema.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS instances (
3434
os_version VARCHAR(50) NOT NULL,
3535
image_registry VARCHAR(255),
3636
image_tag VARCHAR(100),
37+
environment_overrides_json LONGTEXT,
3738
storage_class VARCHAR(50) DEFAULT 'standard',
3839
mount_path VARCHAR(255) DEFAULT '/data',
3940
pod_name VARCHAR(255),
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
SET @instance_environment_overrides_column_exists = (
2+
SELECT COUNT(*)
3+
FROM information_schema.COLUMNS
4+
WHERE TABLE_SCHEMA = DATABASE()
5+
AND TABLE_NAME = 'instances'
6+
AND COLUMN_NAME = 'environment_overrides_json'
7+
);
8+
SET @instance_environment_overrides_column_sql = IF(
9+
@instance_environment_overrides_column_exists = 0,
10+
'ALTER TABLE instances ADD COLUMN environment_overrides_json LONGTEXT NULL AFTER image_tag',
11+
'SELECT 1'
12+
);
13+
PREPARE instance_environment_overrides_column_stmt FROM @instance_environment_overrides_column_sql;
14+
EXECUTE instance_environment_overrides_column_stmt;
15+
DEALLOCATE PREPARE instance_environment_overrides_column_stmt;

backend/internal/handlers/instance_handler.go

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,22 @@ type PublishConfigRevisionRequest struct {
6262

6363
// CreateInstanceRequest represents a create instance request
6464
type CreateInstanceRequest struct {
65-
Name string `json:"name" binding:"required,min=3,max=50"`
66-
Description *string `json:"description,omitempty"`
67-
Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"`
68-
CPUCores float64 `json:"cpu_cores" binding:"required,min=0.1,max=32"`
69-
MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"`
70-
DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"`
71-
GPUEnabled bool `json:"gpu_enabled"`
72-
GPUCount int `json:"gpu_count" binding:"min=0,max=4"`
73-
OSType string `json:"os_type" binding:"required"`
74-
OSVersion string `json:"os_version" binding:"required"`
75-
ImageRegistry *string `json:"image_registry,omitempty"`
76-
ImageTag *string `json:"image_tag,omitempty"`
77-
StorageClass string `json:"storage_class"`
78-
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
79-
SkillIDs []int `json:"skill_ids,omitempty"`
65+
Name string `json:"name" binding:"required,min=3,max=50"`
66+
Description *string `json:"description,omitempty"`
67+
Type string `json:"type" binding:"required,oneof=openclaw ubuntu debian centos custom webtop"`
68+
CPUCores float64 `json:"cpu_cores" binding:"required,min=0.1,max=32"`
69+
MemoryGB int `json:"memory_gb" binding:"required,min=1,max=128"`
70+
DiskGB int `json:"disk_gb" binding:"required,min=10,max=1000"`
71+
GPUEnabled bool `json:"gpu_enabled"`
72+
GPUCount int `json:"gpu_count" binding:"min=0,max=4"`
73+
OSType string `json:"os_type" binding:"required"`
74+
OSVersion string `json:"os_version" binding:"required"`
75+
ImageRegistry *string `json:"image_registry,omitempty"`
76+
ImageTag *string `json:"image_tag,omitempty"`
77+
EnvironmentOverrides map[string]string `json:"environment_overrides,omitempty"`
78+
StorageClass string `json:"storage_class"`
79+
OpenClawConfigPlan *services.OpenClawConfigPlan `json:"openclaw_config_plan,omitempty"`
80+
SkillIDs []int `json:"skill_ids,omitempty"`
8081
}
8182

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

135136
createReq := services.CreateInstanceRequest{
136-
Name: req.Name,
137-
Description: req.Description,
138-
Type: req.Type,
139-
CPUCores: req.CPUCores,
140-
MemoryGB: req.MemoryGB,
141-
DiskGB: req.DiskGB,
142-
GPUEnabled: req.GPUEnabled,
143-
GPUCount: req.GPUCount,
144-
OSType: req.OSType,
145-
OSVersion: req.OSVersion,
146-
ImageRegistry: req.ImageRegistry,
147-
ImageTag: req.ImageTag,
148-
StorageClass: req.StorageClass,
149-
OpenClawConfigPlan: req.OpenClawConfigPlan,
137+
Name: req.Name,
138+
Description: req.Description,
139+
Type: req.Type,
140+
CPUCores: req.CPUCores,
141+
MemoryGB: req.MemoryGB,
142+
DiskGB: req.DiskGB,
143+
GPUEnabled: req.GPUEnabled,
144+
GPUCount: req.GPUCount,
145+
OSType: req.OSType,
146+
OSVersion: req.OSVersion,
147+
ImageRegistry: req.ImageRegistry,
148+
ImageTag: req.ImageTag,
149+
EnvironmentOverrides: req.EnvironmentOverrides,
150+
StorageClass: req.StorageClass,
151+
OpenClawConfigPlan: req.OpenClawConfigPlan,
150152
}
151153

152154
instance, err := h.instanceService.Create(userID.(int), createReq)

backend/internal/models/instance.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type Instance struct {
2222
OSVersion string `db:"os_version" json:"os_version"`
2323
ImageRegistry *string `db:"image_registry" json:"image_registry,omitempty"`
2424
ImageTag *string `db:"image_tag" json:"image_tag,omitempty"`
25+
EnvironmentOverridesJSON *string `db:"environment_overrides_json" json:"-"`
2526
StorageClass string `db:"storage_class" json:"storage_class"`
2627
MountPath string `db:"mount_path" json:"mount_path"`
2728
PodName *string `db:"pod_name" json:"pod_name,omitempty"`
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package services
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"regexp"
7+
"strings"
8+
9+
"clawreef/internal/models"
10+
)
11+
12+
var envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
13+
14+
func normalizeEnvironmentOverrides(overrides map[string]string) (map[string]string, error) {
15+
if len(overrides) == 0 {
16+
return nil, nil
17+
}
18+
19+
normalized := make(map[string]string, len(overrides))
20+
for rawKey, value := range overrides {
21+
key := strings.TrimSpace(rawKey)
22+
if key == "" {
23+
return nil, fmt.Errorf("environment variable name cannot be empty")
24+
}
25+
if !envNamePattern.MatchString(key) {
26+
return nil, fmt.Errorf("invalid environment variable name: %s", key)
27+
}
28+
if _, exists := normalized[key]; exists {
29+
return nil, fmt.Errorf("duplicate environment variable name: %s", key)
30+
}
31+
normalized[key] = value
32+
}
33+
34+
return normalized, nil
35+
}
36+
37+
func marshalEnvironmentOverrides(overrides map[string]string) (*string, error) {
38+
if len(overrides) == 0 {
39+
return nil, nil
40+
}
41+
42+
raw, err := json.Marshal(overrides)
43+
if err != nil {
44+
return nil, fmt.Errorf("failed to encode environment overrides: %w", err)
45+
}
46+
47+
encoded := string(raw)
48+
return &encoded, nil
49+
}
50+
51+
func parseEnvironmentOverridesJSON(raw *string) (map[string]string, error) {
52+
if raw == nil || strings.TrimSpace(*raw) == "" {
53+
return nil, nil
54+
}
55+
56+
var overrides map[string]string
57+
if err := json.Unmarshal([]byte(strings.TrimSpace(*raw)), &overrides); err != nil {
58+
return nil, fmt.Errorf("failed to decode environment overrides: %w", err)
59+
}
60+
61+
normalized, err := normalizeEnvironmentOverrides(overrides)
62+
if err != nil {
63+
return nil, err
64+
}
65+
66+
return normalized, nil
67+
}
68+
69+
func buildInstancePodEnv(instance *models.Instance, runtimeEnv, gatewayEnv, agentEnv map[string]string) (map[string]string, error) {
70+
if instance == nil {
71+
return nil, fmt.Errorf("instance is required")
72+
}
73+
74+
overrides, err := parseEnvironmentOverridesJSON(instance.EnvironmentOverridesJSON)
75+
if err != nil {
76+
return nil, err
77+
}
78+
79+
resolved := mergeEnvMaps(runtimeEnv, mergeEnvMaps(gatewayEnv, agentEnv))
80+
resolved = withInstanceProxyEnv(instance.Type, instance.ID, resolved)
81+
resolved = mergeEnvMaps(resolved, overrides)
82+
83+
return resolved, nil
84+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package services
2+
3+
import (
4+
"testing"
5+
6+
"clawreef/internal/models"
7+
)
8+
9+
func TestNormalizeEnvironmentOverrides(t *testing.T) {
10+
overrides, err := normalizeEnvironmentOverrides(map[string]string{
11+
" FOO ": "bar",
12+
"BAR_2": "",
13+
})
14+
if err != nil {
15+
t.Fatalf("normalizeEnvironmentOverrides returned error: %v", err)
16+
}
17+
18+
if overrides["FOO"] != "bar" {
19+
t.Fatalf("expected trimmed key FOO to be preserved")
20+
}
21+
if value, ok := overrides["BAR_2"]; !ok || value != "" {
22+
t.Fatalf("expected empty override value to be preserved")
23+
}
24+
}
25+
26+
func TestNormalizeEnvironmentOverridesRejectsInvalidNames(t *testing.T) {
27+
if _, err := normalizeEnvironmentOverrides(map[string]string{
28+
"1INVALID": "value",
29+
}); err == nil {
30+
t.Fatalf("expected invalid environment variable name to fail validation")
31+
}
32+
}
33+
34+
func TestBuildInstancePodEnvAppliesOverridesAfterDefaults(t *testing.T) {
35+
t.Setenv("CLAWMANAGER_EGRESS_PROXY_URL", "")
36+
t.Setenv("CLAWMANAGER_SYSTEM_NAMESPACE", "")
37+
t.Setenv("K8S_NAMESPACE", "")
38+
39+
raw, err := marshalEnvironmentOverrides(map[string]string{
40+
"SUBFOLDER": "/custom-proxy",
41+
"CUSTOM": "enabled",
42+
})
43+
if err != nil {
44+
t.Fatalf("marshalEnvironmentOverrides returned error: %v", err)
45+
}
46+
47+
instance := &models.Instance{
48+
ID: 42,
49+
Type: "webtop",
50+
EnvironmentOverridesJSON: raw,
51+
}
52+
53+
env, err := buildInstancePodEnv(instance, map[string]string{
54+
"TITLE": "ClawManager Webtop",
55+
"SUBFOLDER": "/",
56+
}, nil, nil)
57+
if err != nil {
58+
t.Fatalf("buildInstancePodEnv returned error: %v", err)
59+
}
60+
61+
if env["SUBFOLDER"] != "/custom-proxy" {
62+
t.Fatalf("expected SUBFOLDER override to win, got %q", env["SUBFOLDER"])
63+
}
64+
if env["CUSTOM"] != "enabled" {
65+
t.Fatalf("expected custom environment variable to be merged")
66+
}
67+
if env["TITLE"] != "ClawManager Webtop" {
68+
t.Fatalf("expected default environment variable to remain available")
69+
}
70+
}

0 commit comments

Comments
 (0)