- 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)
438 lines
No EOL
13 KiB
Go
438 lines
No EOL
13 KiB
Go
// Package config handles YAML configuration loading for non-interactive mode.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// YAMLConfig represents the YAML configuration file structure for non-interactive deployment.
|
|
// This is the schema for obm-deploy.yaml files used in CI/CD pipelines.
|
|
type YAMLConfig struct {
|
|
// Framework selection (required)
|
|
Framework string `yaml:"framework"` // "hermes" or "openclaw"
|
|
|
|
// Cloud provider configuration
|
|
Provider YAMLProviderConfig `yaml:"provider"`
|
|
|
|
// Server configuration
|
|
Server YAMLServerConfig `yaml:"server"`
|
|
|
|
// Inference configuration
|
|
Inference YAMLInferenceConfig `yaml:"inference"`
|
|
|
|
// Tailscale configuration (optional)
|
|
Tailscale *YAMLTailscaleConfig `yaml:"tailscale,omitempty"`
|
|
|
|
// Discord configuration (optional)
|
|
Discord *YAMLDiscordConfig `yaml:"discord,omitempty"`
|
|
|
|
// Hermes-specific configuration (optional, only for framework: hermes)
|
|
Hermes *YAMLHermesConfig `yaml:"hermes,omitempty"`
|
|
|
|
// OpenClaw-specific configuration (optional, only for framework: openclaw)
|
|
OpenClaw *YAMLOpenClawConfig `yaml:"openclaw,omitempty"`
|
|
|
|
// Gateway configuration (optional, Hermes-only)
|
|
Gateway *YAMLGatewayConfig `yaml:"gateway,omitempty"`
|
|
|
|
// Optional integrations (optional)
|
|
Integrations *YAMLIntegrationsConfig `yaml:"integrations,omitempty"`
|
|
}
|
|
|
|
// YAMLProviderConfig holds cloud provider configuration.
|
|
type YAMLProviderConfig struct {
|
|
// Cloud provider: "hetzner" or "digitalocean" (required)
|
|
Name string `yaml:"name"`
|
|
|
|
// API token (required, sensitive)
|
|
Token string `yaml:"token"`
|
|
|
|
// SSH key configuration
|
|
SSH YAMLSSHConfig `yaml:"ssh"`
|
|
}
|
|
|
|
// YAMLSSHConfig holds SSH key configuration.
|
|
type YAMLSSHConfig struct {
|
|
// For Hetzner: key names as shown in console
|
|
Names []string `yaml:"names,omitempty"`
|
|
|
|
// For DigitalOcean: key fingerprints
|
|
Fingerprints []string `yaml:"fingerprints,omitempty"`
|
|
}
|
|
|
|
// YAMLServerConfig holds server configuration.
|
|
type YAMLServerConfig struct {
|
|
// Server hostname
|
|
Name string `yaml:"name,omitempty"`
|
|
|
|
// Agent name (defaults to framework name)
|
|
AgentName string `yaml:"agent_name,omitempty"`
|
|
|
|
// Timezone (default: UTC)
|
|
Timezone string `yaml:"timezone,omitempty"`
|
|
|
|
// Location for Hetzner: ash, fsn1, nbg1, hel1
|
|
Location string `yaml:"location,omitempty"`
|
|
|
|
// Server type for Hetzner: cpx21, cx23, cpx31
|
|
Type string `yaml:"type,omitempty"`
|
|
|
|
// Region for DigitalOcean: nyc3, sfo2, ams3, etc.
|
|
Region string `yaml:"region,omitempty"`
|
|
|
|
// Droplet size for DigitalOcean
|
|
Size string `yaml:"size,omitempty"`
|
|
}
|
|
|
|
// YAMLInferenceConfig holds inference provider configuration.
|
|
type YAMLInferenceConfig struct {
|
|
// Provider: venice, openrouter, openai, anthropic, custom
|
|
Provider string `yaml:"provider"`
|
|
|
|
// API key (sensitive)
|
|
APIKey string `yaml:"api_key"`
|
|
|
|
// Base URL (optional, for custom providers)
|
|
BaseURL string `yaml:"base_url,omitempty"`
|
|
|
|
// Primary model ID
|
|
PrimaryModel string `yaml:"primary_model,omitempty"`
|
|
|
|
// Primary model display name
|
|
PrimaryModelName string `yaml:"primary_model_name,omitempty"`
|
|
|
|
// Fallback models in priority order
|
|
FallbackModels []string `yaml:"fallback_models,omitempty"`
|
|
|
|
// Fallback providers configuration
|
|
Fallbacks []YAMLFallbackProviderConfig `yaml:"fallbacks,omitempty"`
|
|
}
|
|
|
|
// YAMLFallbackProviderConfig holds fallback inference provider configuration.
|
|
type YAMLFallbackProviderConfig struct {
|
|
Provider string `yaml:"provider"`
|
|
APIKey string `yaml:"api_key"`
|
|
BaseURL string `yaml:"base_url,omitempty"`
|
|
}
|
|
|
|
// YAMLTailscaleConfig holds Tailscale VPN configuration.
|
|
type YAMLTailscaleConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
AuthKey string `yaml:"auth_key"`
|
|
Tailnet string `yaml:"tailnet,omitempty"`
|
|
}
|
|
|
|
// YAMLDiscordConfig holds Discord integration configuration.
|
|
type YAMLDiscordConfig struct {
|
|
Enabled bool `yaml:"enabled"`
|
|
BotToken string `yaml:"bot_token"`
|
|
ServerID string `yaml:"server_id"`
|
|
UserIDs []string `yaml:"user_ids,omitempty"`
|
|
|
|
// Hermes-specific Discord options
|
|
HomeChannel string `yaml:"home_channel,omitempty"`
|
|
AutoThread bool `yaml:"auto_thread,omitempty"`
|
|
}
|
|
|
|
// YAMLHermesConfig holds Hermes-specific configuration.
|
|
type YAMLHermesConfig struct {
|
|
DockerEnabled bool `yaml:"docker_enabled"`
|
|
}
|
|
|
|
// YAMLOpenClawConfig holds OpenClaw-specific configuration.
|
|
type YAMLOpenClawConfig struct {
|
|
Version string `yaml:"version,omitempty"`
|
|
NodeVersion string `yaml:"node_version,omitempty"`
|
|
EnableSwap bool `yaml:"enable_swap,omitempty"`
|
|
SwapSizeGB int `yaml:"swap_size_gb,omitempty"`
|
|
EnableFail2ban bool `yaml:"enable_fail2ban,omitempty"`
|
|
EnableUnattendedUpgrades bool `yaml:"enable_unattended_upgrades,omitempty"`
|
|
}
|
|
|
|
// YAMLGatewayConfig holds Hermes gateway configuration.
|
|
type YAMLGatewayConfig struct {
|
|
Token string `yaml:"token,omitempty"`
|
|
AllowedUsers string `yaml:"allowed_users,omitempty"`
|
|
AllowAll bool `yaml:"allow_all,omitempty"`
|
|
}
|
|
|
|
// YAMLIntegrationsConfig holds optional service integrations.
|
|
type YAMLIntegrationsConfig struct {
|
|
BraveSearchAPIKey string `yaml:"brave_search_api_key,omitempty"`
|
|
}
|
|
|
|
// LoadYAMLConfig loads and validates a YAML configuration file.
|
|
func LoadYAMLConfig(path string) (*YAMLConfig, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read config file: %w", err)
|
|
}
|
|
|
|
var cfg YAMLConfig
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("failed to parse YAML: %w", err)
|
|
}
|
|
|
|
// Set defaults
|
|
applyYAMLDefaults(&cfg)
|
|
|
|
// Validate
|
|
if err := validateYAMLConfig(&cfg); err != nil {
|
|
return nil, fmt.Errorf("validation error: %w", err)
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// LoadConfigFile is an alias for LoadYAMLConfig (convenience).
|
|
func LoadConfigFile(path string) (*YAMLConfig, error) {
|
|
return LoadYAMLConfig(path)
|
|
}
|
|
|
|
// applyYAMLDefaults sets default values for optional fields.
|
|
func applyYAMLDefaults(cfg *YAMLConfig) {
|
|
if cfg.Server.Name == "" {
|
|
cfg.Server.Name = "agent-gateway"
|
|
}
|
|
if cfg.Server.Timezone == "" {
|
|
cfg.Server.Timezone = "UTC"
|
|
}
|
|
|
|
// Hetzner defaults
|
|
if cfg.Provider.Name == "hetzner" {
|
|
if cfg.Server.Location == "" {
|
|
cfg.Server.Location = "ash"
|
|
}
|
|
if cfg.Server.Type == "" {
|
|
cfg.Server.Type = "cpx21"
|
|
}
|
|
}
|
|
|
|
// DigitalOcean defaults
|
|
if cfg.Provider.Name == "digitalocean" {
|
|
if cfg.Server.Region == "" {
|
|
cfg.Server.Region = "nyc3"
|
|
}
|
|
if cfg.Server.Size == "" {
|
|
cfg.Server.Size = "s-2vcpu-4gb"
|
|
}
|
|
}
|
|
|
|
// Inference defaults
|
|
if cfg.Inference.PrimaryModel == "" {
|
|
cfg.Inference.PrimaryModel = defaultModelForProvider(cfg.Inference.Provider)
|
|
}
|
|
|
|
// Framework-specific defaults
|
|
if cfg.Framework == "hermes" {
|
|
if cfg.Hermes == nil {
|
|
cfg.Hermes = &YAMLHermesConfig{}
|
|
}
|
|
if cfg.Server.AgentName == "" {
|
|
cfg.Server.AgentName = "hermes"
|
|
}
|
|
}
|
|
|
|
if cfg.Framework == "openclaw" {
|
|
if cfg.OpenClaw == nil {
|
|
cfg.OpenClaw = &YAMLOpenClawConfig{}
|
|
}
|
|
if cfg.OpenClaw.Version == "" {
|
|
cfg.OpenClaw.Version = "lts"
|
|
}
|
|
if cfg.OpenClaw.NodeVersion == "" {
|
|
cfg.OpenClaw.NodeVersion = "22"
|
|
}
|
|
cfg.OpenClaw.EnableSwap = true
|
|
cfg.OpenClaw.SwapSizeGB = 2
|
|
cfg.OpenClaw.EnableFail2ban = true
|
|
cfg.OpenClaw.EnableUnattendedUpgrades = true
|
|
if cfg.Server.AgentName == "" {
|
|
cfg.Server.AgentName = "openclaw"
|
|
}
|
|
}
|
|
|
|
// Tailscale defaults
|
|
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
|
|
if cfg.Tailscale.Tailnet == "" {
|
|
cfg.Tailscale.Tailnet = "tailnet"
|
|
}
|
|
}
|
|
|
|
// Discord defaults
|
|
if cfg.Discord != nil && cfg.Discord.Enabled && cfg.Framework == "hermes" {
|
|
cfg.Discord.AutoThread = true
|
|
}
|
|
}
|
|
|
|
// validateYAMLConfig validates the configuration file.
|
|
func validateYAMLConfig(cfg *YAMLConfig) error {
|
|
// Required: framework
|
|
if cfg.Framework == "" {
|
|
return fmt.Errorf("framework is required (hermes or openclaw)")
|
|
}
|
|
if cfg.Framework != "hermes" && cfg.Framework != "openclaw" {
|
|
return fmt.Errorf("invalid framework: %s (must be hermes or openclaw)", cfg.Framework)
|
|
}
|
|
|
|
// Required: provider
|
|
if cfg.Provider.Name == "" {
|
|
return fmt.Errorf("provider.name is required (hetzner or digitalocean)")
|
|
}
|
|
if cfg.Provider.Name != "hetzner" && cfg.Provider.Name != "digitalocean" {
|
|
return fmt.Errorf("invalid provider: %s (must be hetzner or digitalocean)", cfg.Provider.Name)
|
|
}
|
|
|
|
// Required: provider token
|
|
if cfg.Provider.Token == "" {
|
|
return fmt.Errorf("provider.token is required")
|
|
}
|
|
|
|
// Required: SSH key (at least one)
|
|
if len(cfg.Provider.SSH.Names) == 0 && len(cfg.Provider.SSH.Fingerprints) == 0 {
|
|
return fmt.Errorf("provider.ssh.names (Hetzner) or provider.ssh.fingerprints (DigitalOcean) is required")
|
|
}
|
|
|
|
// Validate Hetzner location
|
|
if cfg.Provider.Name == "hetzner" &&
|
|
cfg.Server.Location != "" {
|
|
validLocations := map[string]bool{"ash": true, "fsn1": true, "nbg1": true, "hel1": true}
|
|
if !validLocations[cfg.Server.Location] {
|
|
return fmt.Errorf("invalid location: %s (must be one of: ash, fsn1, nbg1, hel1)", cfg.Server.Location)
|
|
}
|
|
}
|
|
|
|
// Validate inference provider
|
|
validProviders := map[string]bool{
|
|
"venice": true, "openrouter": true, "openai": true, "anthropic": true, "custom": true,
|
|
}
|
|
if !validProviders[cfg.Inference.Provider] {
|
|
return fmt.Errorf("invalid inference provider: %s", cfg.Inference.Provider)
|
|
}
|
|
|
|
// Required: inference API key (unless custom with no auth)
|
|
if cfg.Inference.Provider != "custom" && cfg.Inference.APIKey == "" {
|
|
return fmt.Errorf("inference.api_key is required for provider: %s", cfg.Inference.Provider)
|
|
}
|
|
|
|
// Custom provider requires base URL
|
|
if cfg.Inference.Provider == "custom" && cfg.Inference.BaseURL == "" {
|
|
return fmt.Errorf("inference.base_url is required for custom provider")
|
|
}
|
|
|
|
// Tailscale validation
|
|
if cfg.Tailscale != nil && cfg.Tailscale.Enabled {
|
|
if cfg.Tailscale.AuthKey == "" {
|
|
return fmt.Errorf("tailscale.auth_key is required when tailscale is enabled")
|
|
}
|
|
}
|
|
|
|
// Discord validation
|
|
if cfg.Discord != nil && cfg.Discord.Enabled {
|
|
if cfg.Discord.BotToken == "" {
|
|
return fmt.Errorf("discord.bot_token is required when discord is enabled")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToDeploymentConfig converts the YAML config file to a DeploymentConfig.
|
|
func (c *YAMLConfig) ToDeploymentConfig() *DeploymentConfig {
|
|
cfg := &DeploymentConfig{
|
|
Framework: c.Framework,
|
|
CloudProvider: c.Provider.Name,
|
|
ServerName: c.Server.Name,
|
|
AgentName: c.Server.AgentName,
|
|
AgentTimezone: c.Server.Timezone,
|
|
SSHKeyNames: c.Provider.SSH.Names,
|
|
SSHKeyFingerprints: c.Provider.SSH.Fingerprints,
|
|
InferenceProvider: c.Inference.Provider,
|
|
InferenceAPIKey: c.Inference.APIKey,
|
|
InferenceBaseURL: c.Inference.BaseURL,
|
|
PrimaryModel: c.Inference.PrimaryModel,
|
|
PrimaryModelName: c.Inference.PrimaryModelName,
|
|
FallbackModels: c.Inference.FallbackModels,
|
|
}
|
|
|
|
// Provider-specific
|
|
if c.Provider.Name == "hetzner" {
|
|
cfg.HetznerToken = c.Provider.Token
|
|
cfg.Location = c.Server.Location
|
|
cfg.ServerType = c.Server.Type
|
|
} else {
|
|
cfg.DOToken = c.Provider.Token
|
|
cfg.Region = c.Server.Region
|
|
cfg.DropletSize = c.Server.Size
|
|
}
|
|
|
|
// Tailscale
|
|
if c.Tailscale != nil {
|
|
cfg.EnableTailscale = c.Tailscale.Enabled
|
|
cfg.TailscaleAuthKey = c.Tailscale.AuthKey
|
|
cfg.TailnetDomain = c.Tailscale.Tailnet
|
|
}
|
|
|
|
// Discord
|
|
if c.Discord != nil {
|
|
cfg.EnableDiscord = c.Discord.Enabled
|
|
cfg.DiscordBotToken = c.Discord.BotToken
|
|
cfg.DiscordServerID = c.Discord.ServerID
|
|
cfg.DiscordUserIDs = c.Discord.UserIDs
|
|
cfg.DiscordHomeChannel = c.Discord.HomeChannel
|
|
cfg.DiscordAutoThread = c.Discord.AutoThread
|
|
}
|
|
|
|
// Hermes-specific
|
|
if c.Hermes != nil {
|
|
cfg.DockerEnabled = c.Hermes.DockerEnabled
|
|
}
|
|
|
|
// OpenClaw-specific
|
|
if c.OpenClaw != nil {
|
|
cfg.OpenClawVersion = c.OpenClaw.Version
|
|
cfg.NodeVersion = c.OpenClaw.NodeVersion
|
|
cfg.EnableSwap = c.OpenClaw.EnableSwap
|
|
cfg.SwapSizeGB = c.OpenClaw.SwapSizeGB
|
|
cfg.EnableFail2ban = c.OpenClaw.EnableFail2ban
|
|
cfg.EnableUnattendedUpgrades = c.OpenClaw.EnableUnattendedUpgrades
|
|
}
|
|
|
|
// Gateway
|
|
if c.Gateway != nil {
|
|
cfg.GatewayToken = c.Gateway.Token
|
|
cfg.GatewayAllowedUsers = c.Gateway.AllowedUsers
|
|
cfg.GatewayAllowAllUsers = c.Gateway.AllowAll
|
|
}
|
|
|
|
// Integrations
|
|
if c.Integrations != nil {
|
|
cfg.BraveSearchAPIKey = c.Integrations.BraveSearchAPIKey
|
|
}
|
|
|
|
// Fallback providers
|
|
for _, fb := range c.Inference.Fallbacks {
|
|
cfg.FallbackProviders = append(cfg.FallbackProviders, InferenceProviderConfig{
|
|
Provider: fb.Provider,
|
|
APIKey: fb.APIKey,
|
|
BaseURL: fb.BaseURL,
|
|
})
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// defaultModelForProvider returns the default model for a provider.
|
|
func defaultModelForProvider(provider string) string {
|
|
defaults := map[string]string{
|
|
"venice": "zai-org-glm-5",
|
|
"openrouter": "openai/gpt-4o",
|
|
"openai": "gpt-4o",
|
|
"anthropic": "claude-sonnet-4",
|
|
"custom": "",
|
|
}
|
|
return defaults[provider]
|
|
} |