obm/internal/config/yaml.go
MermaidMan d080e107d0 feat: add cross-compile and release pipeline
- 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)
2026-05-22 15:38:55 +00:00

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