obm/internal/deploy/deploy.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

476 lines
15 KiB
Go

package deploy
import (
"fmt"
"os"
"strings"
"github.com/openboatmobile/obm/internal/config"
"github.com/openboatmobile/obm/internal/prompt"
)
// Run executes the interactive deploy walkthrough.
func Run() error {
cfg := &config.DeploymentConfig{}
// Interactive mode - use prompt package for user input
return runInteractive(cfg)
}
// RunFromFile executes deployment from a YAML config file (non-interactive mode).
// This is designed for CI/CD pipelines where all configuration is predefined.
func RunFromFile(configPath string) error {
cfg, err := config.LoadYAMLConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
deployCfg := cfg.ToDeploymentConfig()
// Non-interactive mode - write config and proceed
return writeConfig(deployCfg, configPath)
}
// RunWithConfig executes deployment with a pre-built DeploymentConfig.
// This is useful for programmatic usage where config is constructed elsewhere.
func RunWithConfig(cfg *config.DeploymentConfig) error {
return writeConfig(cfg, "<config>")
}
// runInteractive handles the interactive wizard flow.
func runInteractive(cfg *config.DeploymentConfig) error {
prompt.Header("🚢 OpenBoatmobile — Deploy your AI agent")
stepFramework(cfg)
stepCloudProvider(cfg)
stepProviderToken(cfg)
stepSSHKey(cfg)
stepServerConfig(cfg)
stepInferenceProvider(cfg)
stepTailscale(cfg)
stepDiscord(cfg)
stepSummaryAndWrite(cfg)
return nil
}
func stepFramework(cfg *config.DeploymentConfig) {
prompt.StepHeader(1, "Agent Framework")
idx, err := prompt.Select("Choose your agent framework:", []string{
"Hermes Agent (Nous Research) — Python-based, highly configurable",
"OpenClaw — Node.js-based, simpler setup",
})
if err != nil {
prompt.Error(err.Error())
return
}
cfg.Framework = []string{"hermes", "openclaw"}[idx-1]
prompt.Success(fmt.Sprintf("Selected: %s", cfg.Framework))
if cfg.Framework == "openclaw" {
cfg.OpenClawVersion = "lts"
cfg.NodeVersion = "22"
cfg.EnableSwap = true
cfg.SwapSizeGB = 2
cfg.EnableFail2ban = true
cfg.EnableUnattendedUpgrades = true
}
if cfg.Framework == "hermes" {
cfg.DockerEnabled = true
cfg.VeniceBaseURL = "https://api.venice.ai/api/v1"
cfg.GatewayAllowAllUsers = true
cfg.DiscordAutoThread = true
}
}
func stepCloudProvider(cfg *config.DeploymentConfig) {
prompt.StepHeader(2, "Cloud Provider")
idx, err := prompt.Select("Choose your cloud provider:", []string{
"Hetzner Cloud — from €4.49/mo (recommended, ~70% cheaper)",
"DigitalOcean — from $6/mo (wider region availability)",
})
if err != nil {
prompt.Error(err.Error())
return
}
cfg.CloudProvider = []string{"hetzner", "digitalocean"}[idx-1]
prompt.Success(fmt.Sprintf("Selected: %s", cfg.CloudProvider))
}
func stepProviderToken(cfg *config.DeploymentConfig) {
prompt.StepHeader(3, "Provider API Token")
switch cfg.CloudProvider {
case "hetzner":
prompt.Info("Get yours at: https://console.hetzner.cloud/ → Security → API Tokens")
cfg.HetznerToken = prompt.Password("Hetzner API token")
if cfg.HetznerToken != "" {
prompt.Success("Token saved (will be validated in a future step)")
}
case "digitalocean":
prompt.Info("Get yours at: https://cloud.digitalocean.com/account/api/tokens")
cfg.DOToken = prompt.Password("DigitalOcean API token")
if cfg.DOToken != "" {
prompt.Success("Token saved (will be validated in a future step)")
}
}
}
func stepSSHKey(cfg *config.DeploymentConfig) {
prompt.StepHeader(4, "SSH Key")
if cfg.CloudProvider == "hetzner" {
prompt.Info("Enter the name of your SSH key as shown in Hetzner Cloud Console")
name := prompt.Input("SSH key name", "")
if name != "" {
cfg.SSHKeyNames = []string{name}
prompt.Success(fmt.Sprintf("SSH key: %s", name))
}
} else {
prompt.Info("Enter the fingerprint of your SSH key from DigitalOcean")
fp := prompt.Input("SSH key fingerprint", "")
if fp != "" {
cfg.SSHKeyFingerprints = []string{fp}
prompt.Success(fmt.Sprintf("SSH key fingerprint: %s", prompt.MaskValue(fp)))
}
}
}
func stepServerConfig(cfg *config.DeploymentConfig) {
prompt.StepHeader(5, "Server Configuration")
cfg.ServerName = prompt.Input("Server name", "agent-gateway")
if cfg.CloudProvider == "hetzner" {
idx, _ := prompt.SelectWithDefault("Location:", []string{
"ash — Ashburn, VA (US East)",
"fsn1 — Falkenstein (EU Central)",
"nbg1 — Nuremberg (EU Central)",
"hel1 — Helsinki (EU North)",
}, 1)
cfg.Location = []string{"ash", "fsn1", "nbg1", "hel1"}[idx-1]
idx, _ = prompt.SelectWithDefault("Server type:", []string{
"cpx21 — 3 vCPU, 4 GB RAM, 80 GB (€4.49/mo) — recommended",
"cx23 — 2 vCPU, 4 GB RAM, 80 GB (€5.83/mo)",
"cpx31 — 4 vCPU, 8 GB RAM, 80 GB (€8.98/mo)",
}, 1)
cfg.ServerType = []string{"cpx21", "cx23", "cpx31"}[idx-1]
} else {
idx, _ := prompt.SelectWithDefault("Region:", []string{
"nyc3 — New York (US East)",
"sfo2 — San Francisco (US West)",
"ams3 — Amsterdam (EU)",
"lon1 — London (EU)",
"sgp1 — Singapore (AP)",
}, 1)
cfg.Region = []string{"nyc3", "sfo2", "ams3", "lon1", "sgp1"}[idx-1]
idx, _ = prompt.SelectWithDefault("Droplet size:", []string{
"s-2vcpu-4gb — 2 vCPU, 4 GB RAM ($24/mo)",
"s-4vcpu-8gb — 4 vCPU, 8 GB RAM ($48/mo)",
}, 1)
cfg.DropletSize = []string{"s-2vcpu-4gb", "s-4vcpu-8gb"}[idx-1]
}
cfg.AgentName = prompt.Input("Agent name", cfg.Framework)
cfg.AgentTimezone = prompt.Input("Timezone", "UTC")
if cfg.Framework == "hermes" {
cfg.DockerEnabled = prompt.Confirm("Use Docker deployment?", true)
}
}
func stepInferenceProvider(cfg *config.DeploymentConfig) {
prompt.StepHeader(6, "Inference Provider")
idx, err := prompt.Select("Primary inference provider:", []string{
"Venice AI — uncensored, privacy-focused (recommended)",
"OpenRouter — aggregator with many models",
"OpenAI — GPT-4o, o1, etc.",
"Anthropic — Claude models",
"Custom — OpenAI-compatible endpoint",
})
if err != nil {
prompt.Error(err.Error())
return
}
providers := []string{"venice", "openrouter", "openai", "anthropic", "custom"}
cfg.InferenceProvider = providers[idx-1]
switch cfg.InferenceProvider {
case "venice":
prompt.Info("Get your key at: https://venice.ai → Settings → API Keys")
cfg.InferenceAPIKey = prompt.Password("Venice AI API key")
cfg.InferenceBaseURL = "https://api.venice.ai/api/v1"
cfg.VeniceBaseURL = cfg.InferenceBaseURL
prompt.Success("Venice AI key saved")
case "openrouter":
prompt.Info("Get your key at: https://openrouter.ai/keys")
cfg.InferenceAPIKey = prompt.Password("OpenRouter API key")
cfg.InferenceBaseURL = "https://openrouter.ai/api/v1"
prompt.Success("OpenRouter key saved")
case "openai":
cfg.InferenceAPIKey = prompt.Password("OpenAI API key")
cfg.InferenceBaseURL = "https://api.openai.com/v1"
prompt.Success("OpenAI key saved")
case "anthropic":
cfg.InferenceAPIKey = prompt.Password("Anthropic API key")
cfg.InferenceBaseURL = "https://api.anthropic.com"
prompt.Success("Anthropic key saved")
case "custom":
cfg.InferenceBaseURL = prompt.Input("Base URL", "")
cfg.InferenceAPIKey = prompt.Password("API key")
prompt.Success("Custom provider configured")
}
// Model selection
prompt.Info("Enter model ID (e.g. zai-org-glm-5, gpt-4o, claude-sonnet-4)")
cfg.PrimaryModel = prompt.Input("Primary model", defaultModel(cfg.InferenceProvider))
if cfg.PrimaryModel != "" {
prompt.Success(fmt.Sprintf("Primary model: %s", cfg.PrimaryModel))
}
// Fallback models
if prompt.Confirm("Add fallback models?", false) {
for {
fb := prompt.Input("Fallback model ID (blank to stop)", "")
if fb == "" {
break
}
cfg.FallbackModels = append(cfg.FallbackModels, fb)
}
if len(cfg.FallbackModels) > 0 {
prompt.Success(fmt.Sprintf("Fallback models: %s", strings.Join(cfg.FallbackModels, ", ")))
}
}
}
func stepTailscale(cfg *config.DeploymentConfig) {
prompt.StepHeader(7, "Remote Access")
cfg.EnableTailscale = prompt.Confirm("Install Tailscale for secure remote access? (recommended)", true)
if cfg.EnableTailscale {
prompt.Info("Get your key at: https://login.tailscale.com/admin/settings/keys")
cfg.TailscaleAuthKey = prompt.Password("Tailscale auth key")
cfg.TailnetDomain = prompt.Input("Tailnet domain", "tailnet")
prompt.Success("Tailscale configured")
} else {
prompt.Warn("Without Tailscale, you'll need SSH or another method for remote access")
}
}
func stepDiscord(cfg *config.DeploymentConfig) {
prompt.StepHeader(8, "Discord Integration")
cfg.EnableDiscord = prompt.Confirm("Connect to Discord?", false)
if !cfg.EnableDiscord {
return
}
prompt.Info("Create a bot at: https://discord.com/developers/applications")
cfg.DiscordBotToken = prompt.Password("Discord bot token")
cfg.DiscordServerID = prompt.Input("Server/guild ID", "")
// User IDs
for {
uid := prompt.Input("Discord user ID (blank to stop)", "")
if uid == "" {
break
}
cfg.DiscordUserIDs = append(cfg.DiscordUserIDs, uid)
}
if cfg.Framework == "hermes" {
cfg.DiscordHomeChannel = prompt.Input("Home channel ID", "")
cfg.DiscordAutoThread = prompt.Confirm("Auto-create threads on @mention?", true)
}
prompt.Success("Discord configured")
}
func stepSummaryAndWrite(cfg *config.DeploymentConfig) {
prompt.Divider()
prompt.Header("Configuration Summary")
prompt.Divider()
prompt.SummaryLine("Framework", cfg.Framework)
prompt.SummaryLine("Provider", fmt.Sprintf("%s (%s)", cfg.CloudProvider, config.LocationOrRegion(cfg)))
prompt.SummaryLine("Server", fmt.Sprintf("%s — %s", config.ServerTypeOrDroplet(cfg), cfg.MonthlyCostEstimate()))
prompt.SummaryLine("SSH Key", config.SSHKeySummary(cfg))
prompt.SummaryLine("Inference", fmt.Sprintf("%s → %s", cfg.InferenceProvider, cfg.PrimaryModel))
if len(cfg.FallbackModels) > 0 {
prompt.SummaryLine("Fallbacks", strings.Join(cfg.FallbackModels, ", "))
}
prompt.SummaryLine("Tailscale", boolStr(cfg.EnableTailscale))
prompt.SummaryLine("Discord", boolStr(cfg.EnableDiscord))
if cfg.BraveSearchAPIKey != "" {
prompt.SummaryLine("Brave Search", "configured")
}
prompt.Divider()
if !prompt.Confirm("Write .env file?", true) {
prompt.Warn("Aborted — no files written")
return
}
// Write the config
if err := writeConfig(cfg, ".env"); err != nil {
prompt.Error(err.Error())
return
}
prompt.Success(".env file written")
if prompt.Confirm("Run terraform init && terraform apply?", false) {
prompt.Info("Terraform integration coming soon — for now, run manually:")
fmt.Printf(" source .env && terraform init && terraform apply\n")
}
}
// writeConfig writes the deployment configuration to a .env file.
// For non-interactive mode, sourceFile is used for error messages.
func writeConfig(cfg *config.DeploymentConfig, sourceFile string) error {
envContent := buildEnvFile(cfg)
// Write to .env file
outputPath := ".env"
if err := os.WriteFile(outputPath, []byte(envContent), 0644); err != nil {
return fmt.Errorf("failed to write %s: %w", outputPath, err)
}
// Print summary for non-interactive mode
if sourceFile != ".env" && sourceFile != "<config>" {
fmt.Printf("Configuration loaded from: %s\n", sourceFile)
}
fmt.Printf("Configuration written to: %s\n", outputPath)
return nil
}
// Helpers
func defaultModel(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]
}
func boolStr(b bool) string {
if b {
return "Enabled"
}
return "Disabled"
}
func buildEnvFile(cfg *config.DeploymentConfig) string {
var b strings.Builder
b.WriteString("# Generated by obm\n")
b.WriteString(fmt.Sprintf("# Framework: %s | Provider: %s\n\n", cfg.Framework, cfg.CloudProvider))
b.WriteString(fmt.Sprintf("TF_VAR_cloud_provider=%s\n", cfg.CloudProvider))
b.WriteString(fmt.Sprintf("TF_VAR_agent_framework=%s\n", cfg.Framework))
// Provider token
switch cfg.CloudProvider {
case "hetzner":
b.WriteString(fmt.Sprintf("TF_VAR_hcloud_token=%s\n", cfg.HetznerToken))
case "digitalocean":
b.WriteString(fmt.Sprintf("TF_VAR_do_token=%s\n", cfg.DOToken))
}
// SSH keys
if len(cfg.SSHKeyNames) > 0 {
b.WriteString(fmt.Sprintf("TF_VAR_ssh_key_names='%s'\n", formatJSONArray(cfg.SSHKeyNames)))
}
if len(cfg.SSHKeyFingerprints) > 0 {
b.WriteString(fmt.Sprintf("TF_VAR_ssh_key_fingerprints='%s'\n", formatJSONArray(cfg.SSHKeyFingerprints)))
}
// Server config
b.WriteString(fmt.Sprintf("TF_VAR_server_name=%s\n", cfg.ServerName))
b.WriteString(fmt.Sprintf("TF_VAR_agent_name=%s\n", cfg.AgentName))
b.WriteString(fmt.Sprintf("TF_VAR_agent_timezone=%s\n", cfg.AgentTimezone))
if cfg.CloudProvider == "hetzner" {
b.WriteString(fmt.Sprintf("TF_VAR_location_hetzner=%s\n", cfg.Location))
b.WriteString(fmt.Sprintf("TF_VAR_server_type_hetzner=%s\n", cfg.ServerType))
}
if cfg.CloudProvider == "digitalocean" {
b.WriteString(fmt.Sprintf("TF_VAR_region_digitalocean=%s\n", cfg.Region))
b.WriteString(fmt.Sprintf("TF_VAR_droplet_size_digitalocean=%s\n", cfg.DropletSize))
}
// Inference
switch cfg.InferenceProvider {
case "venice":
b.WriteString(fmt.Sprintf("TF_VAR_venice_api_key=%s\n", cfg.InferenceAPIKey))
if cfg.VeniceBaseURL != "" {
b.WriteString(fmt.Sprintf("TF_VAR_venice_base_url=%s\n", cfg.VeniceBaseURL))
}
case "openrouter":
b.WriteString(fmt.Sprintf("TF_VAR_openrouter_api_key=%s\n", cfg.InferenceAPIKey))
case "openai":
b.WriteString(fmt.Sprintf("TF_VAR_openai_api_key=%s\n", cfg.InferenceAPIKey))
case "anthropic":
b.WriteString(fmt.Sprintf("TF_VAR_anthropic_api_key=%s\n", cfg.InferenceAPIKey))
}
// Models
if cfg.PrimaryModel != "" {
b.WriteString(fmt.Sprintf("TF_VAR_primary_model=%s\n", cfg.PrimaryModel))
}
if len(cfg.FallbackModels) > 0 {
b.WriteString(fmt.Sprintf("TF_VAR_fallback_models='%s'\n", formatJSONArray(cfg.FallbackModels)))
}
// Hermes-specific
if cfg.Framework == "hermes" {
b.WriteString(fmt.Sprintf("TF_VAR_docker_enabled=%v\n", cfg.DockerEnabled))
}
// OpenClaw-specific
if cfg.Framework == "openclaw" {
b.WriteString(fmt.Sprintf("TF_VAR_openclaw_version=%s\n", cfg.OpenClawVersion))
b.WriteString(fmt.Sprintf("TF_VAR_node_version=%s\n", cfg.NodeVersion))
}
// Tailscale
if cfg.EnableTailscale {
b.WriteString("TF_VAR_enable_tailscale=true\n")
b.WriteString(fmt.Sprintf("TF_VAR_tailscale_auth_key=%s\n", cfg.TailscaleAuthKey))
b.WriteString(fmt.Sprintf("TF_VAR_tailscale_tailnet_domain=%s\n", cfg.TailnetDomain))
}
// Discord
if cfg.EnableDiscord {
b.WriteString(fmt.Sprintf("TF_VAR_discord_bot_token=%s\n", cfg.DiscordBotToken))
b.WriteString(fmt.Sprintf("TF_VAR_discord_server_id=%s\n", cfg.DiscordServerID))
if len(cfg.DiscordUserIDs) > 0 {
b.WriteString(fmt.Sprintf("TF_VAR_discord_user_id='%s'\n", formatJSONArray(cfg.DiscordUserIDs)))
}
}
// Optional
if cfg.BraveSearchAPIKey != "" {
b.WriteString(fmt.Sprintf("TF_VAR_brave_search_api_key=%s\n", cfg.BraveSearchAPIKey))
}
return b.String()
}
func formatJSONArray(items []string) string {
quoted := make([]string, len(items))
for i, item := range items {
quoted[i] = fmt.Sprintf(`"%s"`, item)
}
return fmt.Sprintf("[%s]", strings.Join(quoted, ", "))
}