- Enhanced Makefile with cross-compilation for linux/amd64, linux/arm64, darwin/arm64, windows/amd64, windows/arm64 - Added GitHub Actions CI workflow for testing on all platforms - Added GitHub Actions Release workflow triggered by version tags - Added VERSION file for version tracking - Added scripts/release.sh for automated release process - Added Dockerfile for containerized builds - Added CONTRIBUTING.md with release process documentation - Added CHANGELOG.md for version tracking - Updated .gitignore to exclude build artifacts - Fixed unused variable in cmd/obm/main.go - Version now injected via ldflags (main.version, main.gitCommit, main.buildTime)
524 lines
No EOL
11 KiB
Go
524 lines
No EOL
11 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadYAMLConfig(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
content string
|
|
wantErr bool
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "valid minimal config",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
server:
|
|
name: test-server
|
|
inference:
|
|
provider: venice
|
|
api_key: venice-api-key-123
|
|
`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "valid full config",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
server:
|
|
name: test-server
|
|
agent_name: my-agent
|
|
timezone: America/New_York
|
|
location: fsn1
|
|
type: cpx31
|
|
inference:
|
|
provider: venice
|
|
api_key: venice-api-key-123
|
|
primary_model: zai-org-glm-5
|
|
fallback_models:
|
|
- openai/gpt-4o
|
|
tailscale:
|
|
enabled: true
|
|
auth_key: tskey-123
|
|
tailnet: mytailnet
|
|
discord:
|
|
enabled: true
|
|
bot_token: discord-token-123
|
|
server_id: "123456789"
|
|
user_ids:
|
|
- "111222333"
|
|
hermes:
|
|
docker_enabled: true
|
|
`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "digitalocean config",
|
|
content: `framework: openclaw
|
|
provider:
|
|
name: digitalocean
|
|
token: do-token-123
|
|
ssh:
|
|
fingerprints:
|
|
- "aa:bb:cc:dd:ee:ff"
|
|
server:
|
|
name: do-server
|
|
region: nyc3
|
|
size: s-2vcpu-4gb
|
|
inference:
|
|
provider: openai
|
|
api_key: sk-openai-123
|
|
primary_model: gpt-4o
|
|
`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing framework",
|
|
content: `provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "framework is required",
|
|
},
|
|
{
|
|
name: "invalid framework",
|
|
content: `framework: invalid-framework
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "invalid framework",
|
|
},
|
|
{
|
|
name: "missing provider name",
|
|
content: `framework: hermes
|
|
provider:
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "provider.name is required",
|
|
},
|
|
{
|
|
name: "missing provider token",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "provider.token is required",
|
|
},
|
|
{
|
|
name: "missing ssh keys",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "ssh.names",
|
|
},
|
|
{
|
|
name: "missing inference api key",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "inference.api_key",
|
|
},
|
|
{
|
|
name: "custom provider requires base url",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: custom
|
|
api_key: key-123
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "base_url is required",
|
|
},
|
|
{
|
|
name: "custom provider with base url",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: custom
|
|
api_key: key-123
|
|
base_url: https://api.custom.com/v1
|
|
`,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "tailscale enabled but no auth key",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
tailscale:
|
|
enabled: true
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "tailscale.auth_key",
|
|
},
|
|
{
|
|
name: "discord enabled but no bot token",
|
|
content: `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token-123
|
|
ssh:
|
|
names:
|
|
- my-ssh-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
discord:
|
|
enabled: true
|
|
`,
|
|
wantErr: true,
|
|
errMsg: "discord.bot_token",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(tt.content), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadYAMLConfig(path)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("LoadYAMLConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if tt.wantErr && tt.errMsg != "" {
|
|
if err == nil || !contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("LoadYAMLConfig() error = %v, want error containing %q", err, tt.errMsg)
|
|
}
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr && cfg == nil {
|
|
t.Error("LoadYAMLConfig() returned nil config without error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestYAMLConfigToDeploymentConfig(t *testing.T) {
|
|
yamlContent := `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: htoken-123
|
|
ssh:
|
|
names:
|
|
- my-key
|
|
server:
|
|
name: test-server
|
|
agent_name: my-agent
|
|
location: fsn1
|
|
type: cpx31
|
|
inference:
|
|
provider: venice
|
|
api_key: vkey-123
|
|
primary_model: zai-org-glm-5
|
|
fallback_models:
|
|
- openai/gpt-4o
|
|
tailscale:
|
|
enabled: true
|
|
auth_key: tskey-123
|
|
tailnet: mytailnet
|
|
discord:
|
|
enabled: true
|
|
bot_token: dtoken-123
|
|
server_id: "123456"
|
|
user_ids:
|
|
- "111222333"
|
|
hermes:
|
|
docker_enabled: true
|
|
`
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadYAMLConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
|
}
|
|
|
|
deploy := cfg.ToDeploymentConfig()
|
|
|
|
// Check provider
|
|
if deploy.CloudProvider != "hetzner" {
|
|
t.Errorf("CloudProvider = %q, want hetzner", deploy.CloudProvider)
|
|
}
|
|
if deploy.HetznerToken != "htoken-123" {
|
|
t.Errorf("HetznerToken = %q, want htoken-123", deploy.HetznerToken)
|
|
}
|
|
|
|
// Check framework
|
|
if deploy.Framework != "hermes" {
|
|
t.Errorf("Framework = %q, want hermes", deploy.Framework)
|
|
}
|
|
|
|
// Check server
|
|
if deploy.ServerName != "test-server" {
|
|
t.Errorf("ServerName = %q, want test-server", deploy.ServerName)
|
|
}
|
|
if deploy.Location != "fsn1" {
|
|
t.Errorf("Location = %q, want fsn1", deploy.Location)
|
|
}
|
|
if deploy.ServerType != "cpx31" {
|
|
t.Errorf("ServerType = %q, want cpx31", deploy.ServerType)
|
|
}
|
|
|
|
// Check inference
|
|
if deploy.InferenceProvider != "venice" {
|
|
t.Errorf("InferenceProvider = %q, want venice", deploy.InferenceProvider)
|
|
}
|
|
if deploy.InferenceAPIKey != "vkey-123" {
|
|
t.Errorf("InferenceAPIKey = %q, want vkey-123", deploy.InferenceAPIKey)
|
|
}
|
|
if deploy.PrimaryModel != "zai-org-glm-5" {
|
|
t.Errorf("PrimaryModel = %q, want zai-org-glm-5", deploy.PrimaryModel)
|
|
}
|
|
if len(deploy.FallbackModels) != 1 || deploy.FallbackModels[0] != "openai/gpt-4o" {
|
|
t.Errorf("FallbackModels = %v, want [openai/gpt-4o]", deploy.FallbackModels)
|
|
}
|
|
|
|
// Check Tailscale
|
|
if !deploy.EnableTailscale {
|
|
t.Error("EnableTailscale = false, want true")
|
|
}
|
|
if deploy.TailscaleAuthKey != "tskey-123" {
|
|
t.Errorf("TailscaleAuthKey = %q, want tskey-123", deploy.TailscaleAuthKey)
|
|
}
|
|
|
|
// Check Discord
|
|
if !deploy.EnableDiscord {
|
|
t.Error("EnableDiscord = false, want true")
|
|
}
|
|
if deploy.DiscordBotToken != "dtoken-123" {
|
|
t.Errorf("DiscordBotToken = %q, want dtoken-123", deploy.DiscordBotToken)
|
|
}
|
|
|
|
// Check Hermes
|
|
if !deploy.DockerEnabled {
|
|
t.Error("DockerEnabled = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestYAMLConfigDefaults(t *testing.T) {
|
|
// Test minimal config with defaults
|
|
yamlContent := `framework: hermes
|
|
provider:
|
|
name: hetzner
|
|
token: test-token
|
|
ssh:
|
|
names:
|
|
- my-key
|
|
inference:
|
|
provider: venice
|
|
api_key: key-123
|
|
`
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadYAMLConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
|
}
|
|
|
|
// Check defaults
|
|
if cfg.Server.Name != "agent-gateway" {
|
|
t.Errorf("Server.Name default = %q, want agent-gateway", cfg.Server.Name)
|
|
}
|
|
if cfg.Server.Timezone != "UTC" {
|
|
t.Errorf("Server.Timezone default = %q, want UTC", cfg.Server.Timezone)
|
|
}
|
|
if cfg.Server.Location != "ash" {
|
|
t.Errorf("Server.Location default = %q, want ash (Hetzner default)", cfg.Server.Location)
|
|
}
|
|
if cfg.Server.Type != "cpx21" {
|
|
t.Errorf("Server.Type default = %q, want cpx21 (Hetzner default)", cfg.Server.Type)
|
|
}
|
|
if cfg.Inference.PrimaryModel != "zai-org-glm-5" {
|
|
t.Errorf("Inference.PrimaryModel default = %q, want zai-org-glm-5 (Venice default)", cfg.Inference.PrimaryModel)
|
|
}
|
|
if cfg.Server.AgentName != "hermes" {
|
|
t.Errorf("Server.AgentName default = %q, want hermes (framework default)", cfg.Server.AgentName)
|
|
}
|
|
}
|
|
|
|
func TestDigitalOceanConfig(t *testing.T) {
|
|
yamlContent := `framework: openclaw
|
|
provider:
|
|
name: digitalocean
|
|
token: do-token-123
|
|
ssh:
|
|
fingerprints:
|
|
- "aa:bb:cc:dd:ee:ff"
|
|
server:
|
|
name: do-server
|
|
region: sgp1
|
|
size: s-4vcpu-8gb
|
|
inference:
|
|
provider: openai
|
|
api_key: sk-123
|
|
primary_model: gpt-4o
|
|
`
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadYAMLConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
|
}
|
|
|
|
deploy := cfg.ToDeploymentConfig()
|
|
|
|
if deploy.CloudProvider != "digitalocean" {
|
|
t.Errorf("CloudProvider = %q, want digitalocean", deploy.CloudProvider)
|
|
}
|
|
if deploy.DOToken != "do-token-123" {
|
|
t.Errorf("DOToken = %q, want do-token-123", deploy.DOToken)
|
|
}
|
|
if deploy.Region != "sgp1" {
|
|
t.Errorf("Region = %q, want sgp1", deploy.Region)
|
|
}
|
|
if deploy.DropletSize != "s-4vcpu-8gb" {
|
|
t.Errorf("DropletSize = %q, want s-4vcpu-8gb", deploy.DropletSize)
|
|
}
|
|
if len(deploy.SSHKeyFingerprints) != 1 {
|
|
t.Errorf("SSHKeyFingerprints = %v, want 1 element", deploy.SSHKeyFingerprints)
|
|
}
|
|
}
|
|
|
|
func TestOpenClawDefaults(t *testing.T) {
|
|
yamlContent := `framework: openclaw
|
|
provider:
|
|
name: hetzner
|
|
token: test-token
|
|
ssh:
|
|
names:
|
|
- my-key
|
|
inference:
|
|
provider: openai
|
|
api_key: sk-123
|
|
`
|
|
|
|
tmpdir := t.TempDir()
|
|
path := filepath.Join(tmpdir, "config.yaml")
|
|
if err := os.WriteFile(path, []byte(yamlContent), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cfg, err := LoadYAMLConfig(path)
|
|
if err != nil {
|
|
t.Fatalf("LoadYAMLConfig() error = %v", err)
|
|
}
|
|
|
|
// OpenClaw defaults
|
|
if cfg.OpenClaw == nil {
|
|
t.Fatal("OpenClaw config is nil")
|
|
}
|
|
if cfg.OpenClaw.Version != "lts" {
|
|
t.Errorf("OpenClaw.Version default = %q, want lts", cfg.OpenClaw.Version)
|
|
}
|
|
if cfg.OpenClaw.NodeVersion != "22" {
|
|
t.Errorf("OpenClaw.NodeVersion default = %q, want 22", cfg.OpenClaw.NodeVersion)
|
|
}
|
|
if !cfg.OpenClaw.EnableSwap {
|
|
t.Error("OpenClaw.EnableSwap default = false, want true")
|
|
}
|
|
if cfg.OpenClaw.SwapSizeGB != 2 {
|
|
t.Errorf("OpenClaw.SwapSizeGB default = %d, want 2", cfg.OpenClaw.SwapSizeGB)
|
|
}
|
|
if !cfg.OpenClaw.EnableFail2ban {
|
|
t.Error("OpenClaw.EnableFail2ban default = false, want true")
|
|
}
|
|
if !cfg.OpenClaw.EnableUnattendedUpgrades {
|
|
t.Error("OpenClaw.EnableUnattendedUpgrades default = false, want true")
|
|
}
|
|
if cfg.Server.AgentName != "openclaw" {
|
|
t.Errorf("Server.AgentName default = %q, want openclaw", cfg.Server.AgentName)
|
|
}
|
|
} |