// 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] }