diff --git a/go.mod b/go.mod index 82e47f5a..908a27c9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.5 require ( github.com/Snider/Borg v0.1.0 github.com/getkin/kin-openapi v0.133.0 + github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 github.com/leaanthony/debme v1.2.1 github.com/leaanthony/gosod v1.0.4 github.com/minio/selfupdate v0.6.0 @@ -14,6 +15,7 @@ require ( github.com/qdrant/go-client v1.16.2 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.47.0 golang.org/x/mod v0.32.0 golang.org/x/net v0.49.0 golang.org/x/oauth2 v0.34.0 @@ -40,6 +42,7 @@ require ( github.com/go-git/go-git/v5 v5.16.4 // indirect github.com/go-openapi/jsonpointer v0.22.4 // indirect github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/gofrs/flock v0.12.1 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect @@ -56,6 +59,7 @@ require ( github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/tidwall/gjson v1.18.0 // indirect @@ -70,8 +74,8 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yargevad/filepathx v1.0.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - golang.org/x/crypto v0.47.0 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba // indirect google.golang.org/grpc v1.76.0 // indirect diff --git a/go.sum b/go.sum index 8d2c3c67..b29d4f7c 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,10 @@ github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6 github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= @@ -81,6 +85,8 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1 h1:x1cSEj4Ug5mpuZgUHLvUmlc5r//KHFn6iYiRSrRcVy4= +github.com/kluctl/go-embed-python v0.0.0-3.13.1-20241219-1/go.mod h1:3ebNU9QBrNpUO+Hj6bHaGpkh5pymDHQ+wwVPHTE4mCE= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -133,6 +139,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -143,6 +151,7 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -201,6 +210,8 @@ golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -238,5 +249,6 @@ gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cmd/deploy/cmd_ansible.go b/internal/cmd/deploy/cmd_ansible.go new file mode 100644 index 00000000..2dac1132 --- /dev/null +++ b/internal/cmd/deploy/cmd_ansible.go @@ -0,0 +1,312 @@ +package deploy + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/host-uk/core/pkg/ansible" + "github.com/host-uk/core/pkg/cli" + "github.com/spf13/cobra" +) + +var ( + ansibleInventory string + ansibleLimit string + ansibleTags string + ansibleSkipTags string + ansibleVars []string + ansibleVerbose int + ansibleCheck bool +) + +var ansibleCmd = &cobra.Command{ + Use: "ansible ", + Short: "Run Ansible playbooks natively (no Python required)", + Long: `Execute Ansible playbooks using a pure Go implementation. + +This command parses Ansible YAML playbooks and executes them natively, +without requiring Python or ansible-playbook to be installed. + +Supported modules: + - shell, command, raw, script + - copy, template, file, lineinfile, stat, slurp, fetch, get_url + - apt, apt_key, apt_repository, package, pip + - service, systemd + - user, group + - uri, wait_for, git, unarchive + - debug, fail, assert, set_fact, pause + +Examples: + core deploy ansible playbooks/coolify/create.yml -i inventory/ + core deploy ansible site.yml -l production + core deploy ansible deploy.yml -e "version=1.2.3" -e "env=prod"`, + Args: cobra.ExactArgs(1), + RunE: runAnsible, +} + +var ansibleTestCmd = &cobra.Command{ + Use: "test ", + Short: "Test SSH connectivity to a host", + Long: `Test SSH connection and gather facts from a host. + +Examples: + core deploy ansible test linux.snider.dev -u claude -p claude + core deploy ansible test server.example.com -i ~/.ssh/id_rsa`, + Args: cobra.ExactArgs(1), + RunE: runAnsibleTest, +} + +var ( + testUser string + testPassword string + testKeyFile string + testPort int +) + +func init() { + // ansible command flags + ansibleCmd.Flags().StringVarP(&ansibleInventory, "inventory", "i", "", "Inventory file or directory") + ansibleCmd.Flags().StringVarP(&ansibleLimit, "limit", "l", "", "Limit to specific hosts") + ansibleCmd.Flags().StringVarP(&ansibleTags, "tags", "t", "", "Only run plays and tasks tagged with these values") + ansibleCmd.Flags().StringVar(&ansibleSkipTags, "skip-tags", "", "Skip plays and tasks tagged with these values") + ansibleCmd.Flags().StringArrayVarP(&ansibleVars, "extra-vars", "e", nil, "Set additional variables (key=value)") + ansibleCmd.Flags().CountVarP(&ansibleVerbose, "verbose", "v", "Increase verbosity") + ansibleCmd.Flags().BoolVar(&ansibleCheck, "check", false, "Don't make any changes (dry run)") + + // test command flags + ansibleTestCmd.Flags().StringVarP(&testUser, "user", "u", "root", "SSH user") + ansibleTestCmd.Flags().StringVarP(&testPassword, "password", "p", "", "SSH password") + ansibleTestCmd.Flags().StringVarP(&testKeyFile, "key", "i", "", "SSH private key file") + ansibleTestCmd.Flags().IntVar(&testPort, "port", 22, "SSH port") + + // Add subcommands + ansibleCmd.AddCommand(ansibleTestCmd) + Cmd.AddCommand(ansibleCmd) +} + +func runAnsible(cmd *cobra.Command, args []string) error { + playbookPath := args[0] + + // Resolve playbook path + if !filepath.IsAbs(playbookPath) { + cwd, _ := os.Getwd() + playbookPath = filepath.Join(cwd, playbookPath) + } + + if _, err := os.Stat(playbookPath); os.IsNotExist(err) { + return fmt.Errorf("playbook not found: %s", playbookPath) + } + + // Create executor + basePath := filepath.Dir(playbookPath) + executor := ansible.NewExecutor(basePath) + defer executor.Close() + + // Set options + executor.Limit = ansibleLimit + executor.CheckMode = ansibleCheck + executor.Verbose = ansibleVerbose + + if ansibleTags != "" { + executor.Tags = strings.Split(ansibleTags, ",") + } + if ansibleSkipTags != "" { + executor.SkipTags = strings.Split(ansibleSkipTags, ",") + } + + // Parse extra vars + for _, v := range ansibleVars { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + executor.SetVar(parts[0], parts[1]) + } + } + + // Load inventory + if ansibleInventory != "" { + invPath := ansibleInventory + if !filepath.IsAbs(invPath) { + cwd, _ := os.Getwd() + invPath = filepath.Join(cwd, invPath) + } + + // Check if it's a directory + info, err := os.Stat(invPath) + if err != nil { + return fmt.Errorf("inventory not found: %s", invPath) + } + + if info.IsDir() { + // Look for inventory.yml or hosts.yml + for _, name := range []string{"inventory.yml", "hosts.yml", "inventory.yaml", "hosts.yaml"} { + p := filepath.Join(invPath, name) + if _, err := os.Stat(p); err == nil { + invPath = p + break + } + } + } + + if err := executor.SetInventory(invPath); err != nil { + return fmt.Errorf("load inventory: %w", err) + } + } + + // Set up callbacks + executor.OnPlayStart = func(play *ansible.Play) { + fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("PLAY"), cli.BoldStyle.Render("["+play.Name+"]")) + fmt.Println(strings.Repeat("*", 70)) + } + + executor.OnTaskStart = func(host string, task *ansible.Task) { + taskName := task.Name + if taskName == "" { + taskName = task.Module + } + fmt.Printf("\n%s %s\n", cli.TitleStyle.Render("TASK"), cli.BoldStyle.Render("["+taskName+"]")) + if ansibleVerbose > 0 { + fmt.Printf("%s\n", cli.DimStyle.Render("host: "+host)) + } + } + + executor.OnTaskEnd = func(host string, task *ansible.Task, result *ansible.TaskResult) { + status := "ok" + style := cli.SuccessStyle + + if result.Failed { + status = "failed" + style = cli.ErrorStyle + } else if result.Skipped { + status = "skipping" + style = cli.DimStyle + } else if result.Changed { + status = "changed" + style = cli.WarningStyle + } + + fmt.Printf("%s: [%s]", style.Render(status), host) + if result.Msg != "" && ansibleVerbose > 0 { + fmt.Printf(" => %s", result.Msg) + } + if result.Duration > 0 && ansibleVerbose > 1 { + fmt.Printf(" (%s)", result.Duration.Round(time.Millisecond)) + } + fmt.Println() + + if result.Failed && result.Stderr != "" { + fmt.Printf("%s\n", cli.ErrorStyle.Render(result.Stderr)) + } + + if ansibleVerbose > 1 { + if result.Stdout != "" { + fmt.Printf("stdout: %s\n", strings.TrimSpace(result.Stdout)) + } + } + } + + executor.OnPlayEnd = func(play *ansible.Play) { + fmt.Println() + } + + // Run playbook + ctx := context.Background() + start := time.Now() + + fmt.Printf("%s Running playbook: %s\n", cli.BoldStyle.Render("▶"), playbookPath) + + if err := executor.Run(ctx, playbookPath); err != nil { + return fmt.Errorf("playbook failed: %w", err) + } + + fmt.Printf("\n%s Playbook completed in %s\n", + cli.SuccessStyle.Render("✓"), + time.Since(start).Round(time.Millisecond)) + + return nil +} + +func runAnsibleTest(cmd *cobra.Command, args []string) error { + host := args[0] + + fmt.Printf("Testing SSH connection to %s...\n", cli.BoldStyle.Render(host)) + + cfg := ansible.SSHConfig{ + Host: host, + Port: testPort, + User: testUser, + Password: testPassword, + KeyFile: testKeyFile, + Timeout: 30 * time.Second, + } + + client, err := ansible.NewSSHClient(cfg) + if err != nil { + return fmt.Errorf("create client: %w", err) + } + defer client.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Test connection + start := time.Now() + if err := client.Connect(ctx); err != nil { + return fmt.Errorf("connect failed: %w", err) + } + connectTime := time.Since(start) + + fmt.Printf("%s Connected in %s\n", cli.SuccessStyle.Render("✓"), connectTime.Round(time.Millisecond)) + + // Gather facts + fmt.Println("\nGathering facts...") + + // Hostname + stdout, _, _, _ := client.Run(ctx, "hostname -f 2>/dev/null || hostname") + fmt.Printf(" Hostname: %s\n", cli.BoldStyle.Render(strings.TrimSpace(stdout))) + + // OS + stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'\"' -f2") + if stdout != "" { + fmt.Printf(" OS: %s\n", strings.TrimSpace(stdout)) + } + + // Kernel + stdout, _, _, _ = client.Run(ctx, "uname -r") + fmt.Printf(" Kernel: %s\n", strings.TrimSpace(stdout)) + + // Architecture + stdout, _, _, _ = client.Run(ctx, "uname -m") + fmt.Printf(" Architecture: %s\n", strings.TrimSpace(stdout)) + + // Memory + stdout, _, _, _ = client.Run(ctx, "free -h | grep Mem | awk '{print $2}'") + fmt.Printf(" Memory: %s\n", strings.TrimSpace(stdout)) + + // Disk + stdout, _, _, _ = client.Run(ctx, "df -h / | tail -1 | awk '{print $2 \" total, \" $4 \" available\"}'") + fmt.Printf(" Disk: %s\n", strings.TrimSpace(stdout)) + + // Docker + stdout, _, rc, _ := client.Run(ctx, "docker --version 2>/dev/null") + if rc == 0 { + fmt.Printf(" Docker: %s\n", cli.SuccessStyle.Render(strings.TrimSpace(stdout))) + } else { + fmt.Printf(" Docker: %s\n", cli.DimStyle.Render("not installed")) + } + + // Check if Coolify is running + stdout, _, rc, _ = client.Run(ctx, "docker ps 2>/dev/null | grep -q coolify && echo 'running' || echo 'not running'") + if strings.TrimSpace(stdout) == "running" { + fmt.Printf(" Coolify: %s\n", cli.SuccessStyle.Render("running")) + } else { + fmt.Printf(" Coolify: %s\n", cli.DimStyle.Render("not installed")) + } + + fmt.Printf("\n%s SSH test passed\n", cli.SuccessStyle.Render("✓")) + + return nil +} diff --git a/internal/cmd/deploy/cmd_commands.go b/internal/cmd/deploy/cmd_commands.go new file mode 100644 index 00000000..bc61688f --- /dev/null +++ b/internal/cmd/deploy/cmd_commands.go @@ -0,0 +1,15 @@ +package deploy + +import ( + "github.com/host-uk/core/pkg/cli" + "github.com/spf13/cobra" +) + +func init() { + cli.RegisterCommands(AddDeployCommands) +} + +// AddDeployCommands registers the 'deploy' command and all subcommands. +func AddDeployCommands(root *cobra.Command) { + root.AddCommand(Cmd) +} diff --git a/internal/cmd/deploy/cmd_deploy.go b/internal/cmd/deploy/cmd_deploy.go new file mode 100644 index 00000000..a26f988f --- /dev/null +++ b/internal/cmd/deploy/cmd_deploy.go @@ -0,0 +1,280 @@ +package deploy + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/deploy/coolify" + "github.com/host-uk/core/pkg/i18n" + "github.com/spf13/cobra" +) + +var ( + coolifyURL string + coolifyToken string + outputJSON bool +) + +// Cmd is the root deploy command. +var Cmd = &cobra.Command{ + Use: "deploy", + Short: i18n.T("cmd.deploy.short"), + Long: i18n.T("cmd.deploy.long"), +} + +var serversCmd = &cobra.Command{ + Use: "servers", + Short: "List Coolify servers", + RunE: runListServers, +} + +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "List Coolify projects", + RunE: runListProjects, +} + +var appsCmd = &cobra.Command{ + Use: "apps", + Short: "List Coolify applications", + RunE: runListApps, +} + +var dbsCmd = &cobra.Command{ + Use: "databases", + Short: "List Coolify databases", + Aliases: []string{"dbs", "db"}, + RunE: runListDatabases, +} + +var servicesCmd = &cobra.Command{ + Use: "services", + Short: "List Coolify services", + RunE: runListServices, +} + +var teamCmd = &cobra.Command{ + Use: "team", + Short: "Show current team info", + RunE: runTeam, +} + +var callCmd = &cobra.Command{ + Use: "call [params-json]", + Short: "Call any Coolify API operation", + Args: cobra.RangeArgs(1, 2), + RunE: runCall, +} + +func init() { + // Global flags + Cmd.PersistentFlags().StringVar(&coolifyURL, "url", os.Getenv("COOLIFY_URL"), "Coolify API URL") + Cmd.PersistentFlags().StringVar(&coolifyToken, "token", os.Getenv("COOLIFY_TOKEN"), "Coolify API token") + Cmd.PersistentFlags().BoolVar(&outputJSON, "json", false, "Output as JSON") + + // Add subcommands + Cmd.AddCommand(serversCmd) + Cmd.AddCommand(projectsCmd) + Cmd.AddCommand(appsCmd) + Cmd.AddCommand(dbsCmd) + Cmd.AddCommand(servicesCmd) + Cmd.AddCommand(teamCmd) + Cmd.AddCommand(callCmd) +} + +func getClient() (*coolify.Client, error) { + cfg := coolify.Config{ + BaseURL: coolifyURL, + APIToken: coolifyToken, + Timeout: 30, + VerifySSL: true, + } + + if cfg.BaseURL == "" { + cfg.BaseURL = os.Getenv("COOLIFY_URL") + } + if cfg.APIToken == "" { + cfg.APIToken = os.Getenv("COOLIFY_TOKEN") + } + + return coolify.NewClient(cfg) +} + +func outputResult(data any) error { + if outputJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(data) + } + + // Pretty print based on type + switch v := data.(type) { + case []map[string]any: + for _, item := range v { + printItem(item) + } + case map[string]any: + printItem(v) + default: + fmt.Printf("%v\n", data) + } + return nil +} + +func printItem(item map[string]any) { + // Common fields to display + if uuid, ok := item["uuid"].(string); ok { + fmt.Printf("%s ", cli.DimStyle.Render(uuid[:8])) + } + if name, ok := item["name"].(string); ok { + fmt.Printf("%s", cli.TitleStyle.Render(name)) + } + if desc, ok := item["description"].(string); ok && desc != "" { + fmt.Printf(" %s", cli.DimStyle.Render(desc)) + } + if status, ok := item["status"].(string); ok { + switch status { + case "running": + fmt.Printf(" %s", cli.SuccessStyle.Render("●")) + case "stopped": + fmt.Printf(" %s", cli.ErrorStyle.Render("○")) + default: + fmt.Printf(" %s", cli.DimStyle.Render(status)) + } + } + fmt.Println() +} + +func runListServers(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + servers, err := client.ListServers(context.Background()) + if err != nil { + return err + } + + if len(servers) == 0 { + fmt.Println("No servers found") + return nil + } + + return outputResult(servers) +} + +func runListProjects(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + projects, err := client.ListProjects(context.Background()) + if err != nil { + return err + } + + if len(projects) == 0 { + fmt.Println("No projects found") + return nil + } + + return outputResult(projects) +} + +func runListApps(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + apps, err := client.ListApplications(context.Background()) + if err != nil { + return err + } + + if len(apps) == 0 { + fmt.Println("No applications found") + return nil + } + + return outputResult(apps) +} + +func runListDatabases(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + dbs, err := client.ListDatabases(context.Background()) + if err != nil { + return err + } + + if len(dbs) == 0 { + fmt.Println("No databases found") + return nil + } + + return outputResult(dbs) +} + +func runListServices(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + services, err := client.ListServices(context.Background()) + if err != nil { + return err + } + + if len(services) == 0 { + fmt.Println("No services found") + return nil + } + + return outputResult(services) +} + +func runTeam(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + team, err := client.GetTeam(context.Background()) + if err != nil { + return err + } + + return outputResult(team) +} + +func runCall(cmd *cobra.Command, args []string) error { + client, err := getClient() + if err != nil { + return err + } + + operation := args[0] + var params map[string]any + if len(args) > 1 { + if err := json.Unmarshal([]byte(args[1]), ¶ms); err != nil { + return fmt.Errorf("invalid JSON params: %w", err) + } + } + + result, err := client.Call(context.Background(), operation, params) + if err != nil { + return err + } + + return outputResult(result) +} diff --git a/internal/variants/full.go b/internal/variants/full.go index 2a72a48c..e7c50e7f 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -28,6 +28,7 @@ import ( // Commands via self-registration _ "github.com/host-uk/core/internal/cmd/ai" _ "github.com/host-uk/core/internal/cmd/ci" + _ "github.com/host-uk/core/internal/cmd/deploy" _ "github.com/host-uk/core/internal/cmd/dev" _ "github.com/host-uk/core/internal/cmd/docs" _ "github.com/host-uk/core/internal/cmd/doctor" diff --git a/pkg/ansible/executor.go b/pkg/ansible/executor.go new file mode 100644 index 00000000..7b6803b4 --- /dev/null +++ b/pkg/ansible/executor.go @@ -0,0 +1,968 @@ +package ansible + +import ( + "context" + "fmt" + "os" + "regexp" + "strings" + "sync" + "text/template" + "time" +) + +// Executor runs Ansible playbooks. +type Executor struct { + parser *Parser + inventory *Inventory + vars map[string]any + facts map[string]*Facts + results map[string]map[string]*TaskResult // host -> register_name -> result + handlers map[string][]Task + notified map[string]bool + clients map[string]*SSHClient + mu sync.RWMutex + + // Callbacks + OnPlayStart func(play *Play) + OnTaskStart func(host string, task *Task) + OnTaskEnd func(host string, task *Task, result *TaskResult) + OnPlayEnd func(play *Play) + + // Options + Limit string + Tags []string + SkipTags []string + CheckMode bool + Diff bool + Verbose int +} + +// NewExecutor creates a new playbook executor. +func NewExecutor(basePath string) *Executor { + return &Executor{ + parser: NewParser(basePath), + vars: make(map[string]any), + facts: make(map[string]*Facts), + results: make(map[string]map[string]*TaskResult), + handlers: make(map[string][]Task), + notified: make(map[string]bool), + clients: make(map[string]*SSHClient), + } +} + +// SetInventory loads inventory from a file. +func (e *Executor) SetInventory(path string) error { + inv, err := e.parser.ParseInventory(path) + if err != nil { + return err + } + e.inventory = inv + return nil +} + +// SetInventoryDirect sets inventory directly. +func (e *Executor) SetInventoryDirect(inv *Inventory) { + e.inventory = inv +} + +// SetVar sets a variable. +func (e *Executor) SetVar(key string, value any) { + e.mu.Lock() + defer e.mu.Unlock() + e.vars[key] = value +} + +// Run executes a playbook. +func (e *Executor) Run(ctx context.Context, playbookPath string) error { + plays, err := e.parser.ParsePlaybook(playbookPath) + if err != nil { + return fmt.Errorf("parse playbook: %w", err) + } + + for i := range plays { + if err := e.runPlay(ctx, &plays[i]); err != nil { + return fmt.Errorf("play %d (%s): %w", i, plays[i].Name, err) + } + } + + return nil +} + +// runPlay executes a single play. +func (e *Executor) runPlay(ctx context.Context, play *Play) error { + if e.OnPlayStart != nil { + e.OnPlayStart(play) + } + defer func() { + if e.OnPlayEnd != nil { + e.OnPlayEnd(play) + } + }() + + // Get target hosts + hosts := e.getHosts(play.Hosts) + if len(hosts) == 0 { + return nil // No hosts matched + } + + // Merge play vars + for k, v := range play.Vars { + e.vars[k] = v + } + + // Gather facts if needed + gatherFacts := play.GatherFacts == nil || *play.GatherFacts + if gatherFacts { + for _, host := range hosts { + if err := e.gatherFacts(ctx, host, play); err != nil { + // Non-fatal + if e.Verbose > 0 { + fmt.Fprintf(os.Stderr, "Warning: gather facts failed for %s: %v\n", host, err) + } + } + } + } + + // Execute pre_tasks + for _, task := range play.PreTasks { + if err := e.runTaskOnHosts(ctx, hosts, &task, play); err != nil { + return err + } + } + + // Execute roles + for _, roleRef := range play.Roles { + if err := e.runRole(ctx, hosts, &roleRef, play); err != nil { + return err + } + } + + // Execute tasks + for _, task := range play.Tasks { + if err := e.runTaskOnHosts(ctx, hosts, &task, play); err != nil { + return err + } + } + + // Execute post_tasks + for _, task := range play.PostTasks { + if err := e.runTaskOnHosts(ctx, hosts, &task, play); err != nil { + return err + } + } + + // Run notified handlers + for _, handler := range play.Handlers { + if e.notified[handler.Name] { + if err := e.runTaskOnHosts(ctx, hosts, &handler, play); err != nil { + return err + } + } + } + + return nil +} + +// runRole executes a role on hosts. +func (e *Executor) runRole(ctx context.Context, hosts []string, roleRef *RoleRef, play *Play) error { + // Check when condition + if roleRef.When != nil { + if !e.evaluateWhen(roleRef.When, "", nil) { + return nil + } + } + + // Parse role tasks + tasks, err := e.parser.ParseRole(roleRef.Role, roleRef.TasksFrom) + if err != nil { + return fmt.Errorf("parse role %s: %w", roleRef.Role, err) + } + + // Merge role vars + oldVars := make(map[string]any) + for k, v := range e.vars { + oldVars[k] = v + } + for k, v := range roleRef.Vars { + e.vars[k] = v + } + + // Execute tasks + for _, task := range tasks { + if err := e.runTaskOnHosts(ctx, hosts, &task, play); err != nil { + // Restore vars + e.vars = oldVars + return err + } + } + + // Restore vars + e.vars = oldVars + return nil +} + +// runTaskOnHosts runs a task on all hosts. +func (e *Executor) runTaskOnHosts(ctx context.Context, hosts []string, task *Task, play *Play) error { + // Check tags + if !e.matchesTags(task.Tags) { + return nil + } + + // Handle block tasks + if len(task.Block) > 0 { + return e.runBlock(ctx, hosts, task, play) + } + + // Handle include/import + if task.IncludeTasks != "" || task.ImportTasks != "" { + return e.runIncludeTasks(ctx, hosts, task, play) + } + if task.IncludeRole != nil || task.ImportRole != nil { + return e.runIncludeRole(ctx, hosts, task, play) + } + + for _, host := range hosts { + if err := e.runTaskOnHost(ctx, host, task, play); err != nil { + if !task.IgnoreErrors { + return err + } + } + } + + return nil +} + +// runTaskOnHost runs a task on a single host. +func (e *Executor) runTaskOnHost(ctx context.Context, host string, task *Task, play *Play) error { + start := time.Now() + + if e.OnTaskStart != nil { + e.OnTaskStart(host, task) + } + + // Initialize host results + if e.results[host] == nil { + e.results[host] = make(map[string]*TaskResult) + } + + // Check when condition + if task.When != nil { + if !e.evaluateWhen(task.When, host, task) { + result := &TaskResult{Skipped: true, Msg: "Skipped due to when condition"} + if task.Register != "" { + e.results[host][task.Register] = result + } + if e.OnTaskEnd != nil { + e.OnTaskEnd(host, task, result) + } + return nil + } + } + + // Get SSH client + client, err := e.getClient(host, play) + if err != nil { + return fmt.Errorf("get client for %s: %w", host, err) + } + + // Handle loops + if task.Loop != nil { + return e.runLoop(ctx, host, client, task, play) + } + + // Execute the task + result, err := e.executeModule(ctx, host, client, task, play) + if err != nil { + result = &TaskResult{Failed: true, Msg: err.Error()} + } + result.Duration = time.Since(start) + + // Store result + if task.Register != "" { + e.results[host][task.Register] = result + } + + // Handle notify + if result.Changed && task.Notify != nil { + e.handleNotify(task.Notify) + } + + if e.OnTaskEnd != nil { + e.OnTaskEnd(host, task, result) + } + + if result.Failed && !task.IgnoreErrors { + return fmt.Errorf("task failed: %s", result.Msg) + } + + return nil +} + +// runLoop handles task loops. +func (e *Executor) runLoop(ctx context.Context, host string, client *SSHClient, task *Task, play *Play) error { + items := e.resolveLoop(task.Loop, host) + + loopVar := "item" + if task.LoopControl != nil && task.LoopControl.LoopVar != "" { + loopVar = task.LoopControl.LoopVar + } + + var results []TaskResult + for i, item := range items { + // Set loop variables + e.vars[loopVar] = item + if task.LoopControl != nil && task.LoopControl.IndexVar != "" { + e.vars[task.LoopControl.IndexVar] = i + } + + result, err := e.executeModule(ctx, host, client, task, play) + if err != nil { + result = &TaskResult{Failed: true, Msg: err.Error()} + } + results = append(results, *result) + + if result.Failed && !task.IgnoreErrors { + break + } + } + + // Store combined result + if task.Register != "" { + combined := &TaskResult{ + Results: results, + Changed: false, + } + for _, r := range results { + if r.Changed { + combined.Changed = true + } + if r.Failed { + combined.Failed = true + } + } + e.results[host][task.Register] = combined + } + + return nil +} + +// runBlock handles block/rescue/always. +func (e *Executor) runBlock(ctx context.Context, hosts []string, task *Task, play *Play) error { + var blockErr error + + // Try block + for _, t := range task.Block { + if err := e.runTaskOnHosts(ctx, hosts, &t, play); err != nil { + blockErr = err + break + } + } + + // Run rescue if block failed + if blockErr != nil && len(task.Rescue) > 0 { + for _, t := range task.Rescue { + if err := e.runTaskOnHosts(ctx, hosts, &t, play); err != nil { + // Rescue also failed + break + } + } + } + + // Always run always block + for _, t := range task.Always { + e.runTaskOnHosts(ctx, hosts, &t, play) //nolint:errcheck + } + + if blockErr != nil && len(task.Rescue) == 0 { + return blockErr + } + + return nil +} + +// runIncludeTasks handles include_tasks/import_tasks. +func (e *Executor) runIncludeTasks(ctx context.Context, hosts []string, task *Task, play *Play) error { + path := task.IncludeTasks + if path == "" { + path = task.ImportTasks + } + + // Resolve path relative to playbook + path = e.templateString(path, "", nil) + + tasks, err := e.parser.ParseTasks(path) + if err != nil { + return fmt.Errorf("include_tasks %s: %w", path, err) + } + + for _, t := range tasks { + if err := e.runTaskOnHosts(ctx, hosts, &t, play); err != nil { + return err + } + } + + return nil +} + +// runIncludeRole handles include_role/import_role. +func (e *Executor) runIncludeRole(ctx context.Context, hosts []string, task *Task, play *Play) error { + var roleName, tasksFrom string + var roleVars map[string]any + + if task.IncludeRole != nil { + roleName = task.IncludeRole.Name + tasksFrom = task.IncludeRole.TasksFrom + roleVars = task.IncludeRole.Vars + } else { + roleName = task.ImportRole.Name + tasksFrom = task.ImportRole.TasksFrom + roleVars = task.ImportRole.Vars + } + + roleRef := &RoleRef{ + Role: roleName, + TasksFrom: tasksFrom, + Vars: roleVars, + } + + return e.runRole(ctx, hosts, roleRef, play) +} + +// getHosts returns hosts matching the pattern. +func (e *Executor) getHosts(pattern string) []string { + if e.inventory == nil { + if pattern == "localhost" { + return []string{"localhost"} + } + return nil + } + + hosts := GetHosts(e.inventory, pattern) + + // Apply limit - filter to hosts that are also in the limit group + if e.Limit != "" { + limitHosts := GetHosts(e.inventory, e.Limit) + limitSet := make(map[string]bool) + for _, h := range limitHosts { + limitSet[h] = true + } + + var filtered []string + for _, h := range hosts { + if limitSet[h] || h == e.Limit || strings.Contains(h, e.Limit) { + filtered = append(filtered, h) + } + } + hosts = filtered + } + + return hosts +} + +// getClient returns or creates an SSH client for a host. +func (e *Executor) getClient(host string, play *Play) (*SSHClient, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if client, ok := e.clients[host]; ok { + return client, nil + } + + // Get host vars + vars := make(map[string]any) + if e.inventory != nil { + vars = GetHostVars(e.inventory, host) + } + + // Merge with play vars + for k, v := range e.vars { + if _, exists := vars[k]; !exists { + vars[k] = v + } + } + + // Build SSH config + cfg := SSHConfig{ + Host: host, + Port: 22, + User: "root", + } + + if h, ok := vars["ansible_host"].(string); ok { + cfg.Host = h + } + if p, ok := vars["ansible_port"].(int); ok { + cfg.Port = p + } + if u, ok := vars["ansible_user"].(string); ok { + cfg.User = u + } + if p, ok := vars["ansible_password"].(string); ok { + cfg.Password = p + } + if k, ok := vars["ansible_ssh_private_key_file"].(string); ok { + cfg.KeyFile = k + } + + // Apply play become settings + if play.Become { + cfg.Become = true + cfg.BecomeUser = play.BecomeUser + if bp, ok := vars["ansible_become_password"].(string); ok { + cfg.BecomePass = bp + } else if cfg.Password != "" { + // Use SSH password for sudo if no become password specified + cfg.BecomePass = cfg.Password + } + } + + client, err := NewSSHClient(cfg) + if err != nil { + return nil, err + } + + e.clients[host] = client + return client, nil +} + +// gatherFacts collects facts from a host. +func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) error { + if play.Connection == "local" || host == "localhost" { + // Local facts + e.facts[host] = &Facts{ + Hostname: "localhost", + } + return nil + } + + client, err := e.getClient(host, play) + if err != nil { + return err + } + + // Gather basic facts + facts := &Facts{} + + // Hostname + stdout, _, _, err := client.Run(ctx, "hostname -f 2>/dev/null || hostname") + if err == nil { + facts.FQDN = strings.TrimSpace(stdout) + } + + stdout, _, _, err = client.Run(ctx, "hostname -s 2>/dev/null || hostname") + if err == nil { + facts.Hostname = strings.TrimSpace(stdout) + } + + // OS info + stdout, _, _, _ = client.Run(ctx, "cat /etc/os-release 2>/dev/null | grep -E '^(ID|VERSION_ID)=' | head -2") + for _, line := range strings.Split(stdout, "\n") { + if strings.HasPrefix(line, "ID=") { + facts.Distribution = strings.Trim(strings.TrimPrefix(line, "ID="), "\"") + } + if strings.HasPrefix(line, "VERSION_ID=") { + facts.Version = strings.Trim(strings.TrimPrefix(line, "VERSION_ID="), "\"") + } + } + + // Architecture + stdout, _, _, _ = client.Run(ctx, "uname -m") + facts.Architecture = strings.TrimSpace(stdout) + + // Kernel + stdout, _, _, _ = client.Run(ctx, "uname -r") + facts.Kernel = strings.TrimSpace(stdout) + + e.mu.Lock() + e.facts[host] = facts + e.mu.Unlock() + + // Also set as vars + e.SetVar("ansible_hostname", facts.Hostname) + e.SetVar("ansible_fqdn", facts.FQDN) + e.SetVar("ansible_distribution", facts.Distribution) + e.SetVar("ansible_distribution_version", facts.Version) + e.SetVar("ansible_architecture", facts.Architecture) + e.SetVar("ansible_kernel", facts.Kernel) + + return nil +} + +// evaluateWhen evaluates a when condition. +func (e *Executor) evaluateWhen(when any, host string, task *Task) bool { + conditions := normalizeConditions(when) + + for _, cond := range conditions { + cond = e.templateString(cond, host, task) + if !e.evalCondition(cond, host) { + return false + } + } + + return true +} + +func normalizeConditions(when any) []string { + switch v := when.(type) { + case string: + return []string{v} + case []any: + var conds []string + for _, c := range v { + if s, ok := c.(string); ok { + conds = append(conds, s) + } + } + return conds + case []string: + return v + } + return nil +} + +// evalCondition evaluates a single condition. +func (e *Executor) evalCondition(cond string, host string) bool { + cond = strings.TrimSpace(cond) + + // Handle negation + if strings.HasPrefix(cond, "not ") { + return !e.evalCondition(strings.TrimPrefix(cond, "not "), host) + } + + // Handle boolean literals + if cond == "true" || cond == "True" { + return true + } + if cond == "false" || cond == "False" { + return false + } + + // Handle registered variable checks + // e.g., "result is success", "result.rc == 0" + if strings.Contains(cond, " is ") { + parts := strings.SplitN(cond, " is ", 2) + varName := strings.TrimSpace(parts[0]) + check := strings.TrimSpace(parts[1]) + + result := e.getRegisteredVar(host, varName) + if result == nil { + return check == "not defined" || check == "undefined" + } + + switch check { + case "defined": + return true + case "not defined", "undefined": + return false + case "success", "succeeded": + return !result.Failed + case "failed": + return result.Failed + case "changed": + return result.Changed + case "skipped": + return result.Skipped + } + } + + // Handle simple var checks + if strings.Contains(cond, " | default(") { + // Extract var name and check if defined + re := regexp.MustCompile(`(\w+)\s*\|\s*default\([^)]*\)`) + if match := re.FindStringSubmatch(cond); len(match) > 1 { + // Has default, so condition is satisfied + return true + } + } + + // Check if it's a variable that should be truthy + if result := e.getRegisteredVar(host, cond); result != nil { + return !result.Failed && !result.Skipped + } + + // Check vars + if val, ok := e.vars[cond]; ok { + switch v := val.(type) { + case bool: + return v + case string: + return v != "" && v != "false" && v != "False" + case int: + return v != 0 + } + } + + // Default to true for unknown conditions (be permissive) + return true +} + +// getRegisteredVar gets a registered task result. +func (e *Executor) getRegisteredVar(host string, name string) *TaskResult { + e.mu.RLock() + defer e.mu.RUnlock() + + // Handle dotted access (e.g., "result.stdout") + parts := strings.SplitN(name, ".", 2) + varName := parts[0] + + if hostResults, ok := e.results[host]; ok { + if result, ok := hostResults[varName]; ok { + return result + } + } + + return nil +} + +// templateString applies Jinja2-like templating. +func (e *Executor) templateString(s string, host string, task *Task) string { + // Handle {{ var }} syntax + re := regexp.MustCompile(`\{\{\s*([^}]+)\s*\}\}`) + + return re.ReplaceAllStringFunc(s, func(match string) string { + expr := strings.TrimSpace(match[2 : len(match)-2]) + return e.resolveExpr(expr, host, task) + }) +} + +// resolveExpr resolves a template expression. +func (e *Executor) resolveExpr(expr string, host string, task *Task) string { + // Handle filters + if strings.Contains(expr, " | ") { + parts := strings.SplitN(expr, " | ", 2) + value := e.resolveExpr(parts[0], host, task) + return e.applyFilter(value, parts[1]) + } + + // Handle lookups + if strings.HasPrefix(expr, "lookup(") { + return e.handleLookup(expr) + } + + // Handle registered vars + if strings.Contains(expr, ".") { + parts := strings.SplitN(expr, ".", 2) + if result := e.getRegisteredVar(host, parts[0]); result != nil { + switch parts[1] { + case "stdout": + return result.Stdout + case "stderr": + return result.Stderr + case "rc": + return fmt.Sprintf("%d", result.RC) + case "changed": + return fmt.Sprintf("%t", result.Changed) + case "failed": + return fmt.Sprintf("%t", result.Failed) + } + } + } + + // Check vars + if val, ok := e.vars[expr]; ok { + return fmt.Sprintf("%v", val) + } + + // Check task vars + if task != nil { + if val, ok := task.Vars[expr]; ok { + return fmt.Sprintf("%v", val) + } + } + + // Check host vars + if e.inventory != nil { + hostVars := GetHostVars(e.inventory, host) + if val, ok := hostVars[expr]; ok { + return fmt.Sprintf("%v", val) + } + } + + // Check facts + if facts, ok := e.facts[host]; ok { + switch expr { + case "ansible_hostname": + return facts.Hostname + case "ansible_fqdn": + return facts.FQDN + case "ansible_distribution": + return facts.Distribution + } + } + + return "{{ " + expr + " }}" // Return as-is if unresolved +} + +// applyFilter applies a Jinja2 filter. +func (e *Executor) applyFilter(value, filter string) string { + filter = strings.TrimSpace(filter) + + // Handle default filter + if strings.HasPrefix(filter, "default(") { + if value == "" || value == "{{ "+filter+" }}" { + // Extract default value + re := regexp.MustCompile(`default\(([^)]*)\)`) + if match := re.FindStringSubmatch(filter); len(match) > 1 { + return strings.Trim(match[1], "'\"") + } + } + return value + } + + // Handle bool filter + if filter == "bool" { + lower := strings.ToLower(value) + if lower == "true" || lower == "yes" || lower == "1" { + return "true" + } + return "false" + } + + // Handle trim + if filter == "trim" { + return strings.TrimSpace(value) + } + + // Handle b64decode + if filter == "b64decode" { + // Would need base64 decode + return value + } + + return value +} + +// handleLookup handles lookup() expressions. +func (e *Executor) handleLookup(expr string) string { + // Parse lookup('type', 'arg') + re := regexp.MustCompile(`lookup\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]\s*`) + match := re.FindStringSubmatch(expr) + if len(match) < 3 { + return "" + } + + lookupType := match[1] + arg := match[2] + + switch lookupType { + case "env": + return os.Getenv(arg) + case "file": + if data, err := os.ReadFile(arg); err == nil { + return string(data) + } + } + + return "" +} + +// resolveLoop resolves loop items. +func (e *Executor) resolveLoop(loop any, host string) []any { + switch v := loop.(type) { + case []any: + return v + case []string: + items := make([]any, len(v)) + for i, s := range v { + items[i] = s + } + return items + case string: + // Template the string and see if it's a var reference + resolved := e.templateString(v, host, nil) + if val, ok := e.vars[resolved]; ok { + if items, ok := val.([]any); ok { + return items + } + } + } + return nil +} + +// matchesTags checks if task tags match execution tags. +func (e *Executor) matchesTags(taskTags []string) bool { + // If no tags specified, run all + if len(e.Tags) == 0 && len(e.SkipTags) == 0 { + return true + } + + // Check skip tags + for _, skip := range e.SkipTags { + for _, tt := range taskTags { + if skip == tt { + return false + } + } + } + + // Check include tags + if len(e.Tags) > 0 { + for _, tag := range e.Tags { + for _, tt := range taskTags { + if tag == tt || tag == "all" { + return true + } + } + } + return false + } + + return true +} + +// handleNotify marks handlers as notified. +func (e *Executor) handleNotify(notify any) { + switch v := notify.(type) { + case string: + e.notified[v] = true + case []any: + for _, n := range v { + if s, ok := n.(string); ok { + e.notified[s] = true + } + } + case []string: + for _, s := range v { + e.notified[s] = true + } + } +} + +// Close closes all SSH connections. +func (e *Executor) Close() { + e.mu.Lock() + defer e.mu.Unlock() + + for _, client := range e.clients { + client.Close() + } + e.clients = make(map[string]*SSHClient) +} + +// TemplateFile processes a template file. +func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) { + content, err := os.ReadFile(src) + if err != nil { + return "", err + } + + // Convert Jinja2 to Go template syntax (basic conversion) + tmplContent := string(content) + tmplContent = strings.ReplaceAll(tmplContent, "{{", "{{ .") + tmplContent = strings.ReplaceAll(tmplContent, "{%", "{{") + tmplContent = strings.ReplaceAll(tmplContent, "%}", "}}") + + tmpl, err := template.New("template").Parse(tmplContent) + if err != nil { + // Fall back to simple replacement + return e.templateString(string(content), host, task), nil + } + + var buf strings.Builder + if err := tmpl.Execute(&buf, e.vars); err != nil { + return e.templateString(string(content), host, task), nil + } + + return buf.String(), nil +} diff --git a/pkg/ansible/modules.go b/pkg/ansible/modules.go new file mode 100644 index 00000000..2e81a53e --- /dev/null +++ b/pkg/ansible/modules.go @@ -0,0 +1,1418 @@ +package ansible + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// executeModule dispatches to the appropriate module handler. +func (e *Executor) executeModule(ctx context.Context, host string, client *SSHClient, task *Task, play *Play) (*TaskResult, error) { + module := NormalizeModule(task.Module) + + // Apply task-level become + if task.Become != nil && *task.Become { + client.SetBecome(true, task.BecomeUser, "") + } + + // Template the args + args := e.templateArgs(task.Args, host, task) + + switch module { + // Command execution + case "ansible.builtin.shell": + return e.moduleShell(ctx, client, args) + case "ansible.builtin.command": + return e.moduleCommand(ctx, client, args) + case "ansible.builtin.raw": + return e.moduleRaw(ctx, client, args) + case "ansible.builtin.script": + return e.moduleScript(ctx, client, args) + + // File operations + case "ansible.builtin.copy": + return e.moduleCopy(ctx, client, args, host, task) + case "ansible.builtin.template": + return e.moduleTemplate(ctx, client, args, host, task) + case "ansible.builtin.file": + return e.moduleFile(ctx, client, args) + case "ansible.builtin.lineinfile": + return e.moduleLineinfile(ctx, client, args) + case "ansible.builtin.stat": + return e.moduleStat(ctx, client, args) + case "ansible.builtin.slurp": + return e.moduleSlurp(ctx, client, args) + case "ansible.builtin.fetch": + return e.moduleFetch(ctx, client, args) + case "ansible.builtin.get_url": + return e.moduleGetURL(ctx, client, args) + + // Package management + case "ansible.builtin.apt": + return e.moduleApt(ctx, client, args) + case "ansible.builtin.apt_key": + return e.moduleAptKey(ctx, client, args) + case "ansible.builtin.apt_repository": + return e.moduleAptRepository(ctx, client, args) + case "ansible.builtin.package": + return e.modulePackage(ctx, client, args) + case "ansible.builtin.pip": + return e.modulePip(ctx, client, args) + + // Service management + case "ansible.builtin.service": + return e.moduleService(ctx, client, args) + case "ansible.builtin.systemd": + return e.moduleSystemd(ctx, client, args) + + // User/Group + case "ansible.builtin.user": + return e.moduleUser(ctx, client, args) + case "ansible.builtin.group": + return e.moduleGroup(ctx, client, args) + + // HTTP + case "ansible.builtin.uri": + return e.moduleURI(ctx, client, args) + + // Misc + case "ansible.builtin.debug": + return e.moduleDebug(args) + case "ansible.builtin.fail": + return e.moduleFail(args) + case "ansible.builtin.assert": + return e.moduleAssert(args, host) + case "ansible.builtin.set_fact": + return e.moduleSetFact(args) + case "ansible.builtin.pause": + return e.modulePause(ctx, args) + case "ansible.builtin.wait_for": + return e.moduleWaitFor(ctx, client, args) + case "ansible.builtin.git": + return e.moduleGit(ctx, client, args) + case "ansible.builtin.unarchive": + return e.moduleUnarchive(ctx, client, args) + + // Additional modules + case "ansible.builtin.hostname": + return e.moduleHostname(ctx, client, args) + case "ansible.builtin.sysctl": + return e.moduleSysctl(ctx, client, args) + case "ansible.builtin.cron": + return e.moduleCron(ctx, client, args) + case "ansible.builtin.blockinfile": + return e.moduleBlockinfile(ctx, client, args) + case "ansible.builtin.include_vars": + return e.moduleIncludeVars(args) + case "ansible.builtin.meta": + return e.moduleMeta(args) + case "ansible.builtin.setup": + return e.moduleSetup(ctx, client) + case "ansible.builtin.reboot": + return e.moduleReboot(ctx, client, args) + + // Community modules (basic support) + case "community.general.ufw": + return e.moduleUFW(ctx, client, args) + case "ansible.posix.authorized_key": + return e.moduleAuthorizedKey(ctx, client, args) + case "community.docker.docker_compose": + return e.moduleDockerCompose(ctx, client, args) + + default: + // For unknown modules, try to execute as shell if it looks like a command + if strings.Contains(task.Module, " ") || task.Module == "" { + return e.moduleShell(ctx, client, args) + } + return nil, fmt.Errorf("unsupported module: %s", module) + } +} + +// templateArgs templates all string values in args. +func (e *Executor) templateArgs(args map[string]any, host string, task *Task) map[string]any { + // Set inventory_hostname for templating + e.vars["inventory_hostname"] = host + + result := make(map[string]any) + for k, v := range args { + switch val := v.(type) { + case string: + result[k] = e.templateString(val, host, task) + case map[string]any: + // Recurse for nested maps + result[k] = e.templateArgs(val, host, task) + case []any: + // Template strings in arrays + templated := make([]any, len(val)) + for i, item := range val { + if s, ok := item.(string); ok { + templated[i] = e.templateString(s, host, task) + } else { + templated[i] = item + } + } + result[k] = templated + default: + result[k] = v + } + } + return result +} + +// --- Command Modules --- + +func (e *Executor) moduleShell(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + cmd = getStringArg(args, "cmd", "") + } + if cmd == "" { + return nil, fmt.Errorf("shell: no command specified") + } + + // Handle chdir + if chdir := getStringArg(args, "chdir", ""); chdir != "" { + cmd = fmt.Sprintf("cd %q && %s", chdir, cmd) + } + + stdout, stderr, rc, err := client.RunScript(ctx, cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error(), Stdout: stdout, Stderr: stderr, RC: rc}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +func (e *Executor) moduleCommand(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + cmd = getStringArg(args, "cmd", "") + } + if cmd == "" { + return nil, fmt.Errorf("command: no command specified") + } + + // Handle chdir + if chdir := getStringArg(args, "chdir", ""); chdir != "" { + cmd = fmt.Sprintf("cd %q && %s", chdir, cmd) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +func (e *Executor) moduleRaw(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + cmd := getStringArg(args, "_raw_params", "") + if cmd == "" { + return nil, fmt.Errorf("raw: no command specified") + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + }, nil +} + +func (e *Executor) moduleScript(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + script := getStringArg(args, "_raw_params", "") + if script == "" { + return nil, fmt.Errorf("script: no script specified") + } + + // Read local script + content, err := os.ReadFile(script) + if err != nil { + return nil, fmt.Errorf("read script: %w", err) + } + + stdout, stderr, rc, err := client.RunScript(ctx, string(content)) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + return &TaskResult{ + Changed: true, + Stdout: stdout, + Stderr: stderr, + RC: rc, + Failed: rc != 0, + }, nil +} + +// --- File Modules --- + +func (e *Executor) moduleCopy(ctx context.Context, client *SSHClient, args map[string]any, host string, task *Task) (*TaskResult, error) { + dest := getStringArg(args, "dest", "") + if dest == "" { + return nil, fmt.Errorf("copy: dest required") + } + + var content []byte + var err error + + if src := getStringArg(args, "src", ""); src != "" { + content, err = os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("read src: %w", err) + } + } else if c := getStringArg(args, "content", ""); c != "" { + content = []byte(c) + } else { + return nil, fmt.Errorf("copy: src or content required") + } + + mode := os.FileMode(0644) + if m := getStringArg(args, "mode", ""); m != "" { + if parsed, err := strconv.ParseInt(m, 8, 32); err == nil { + mode = os.FileMode(parsed) + } + } + + err = client.Upload(ctx, strings.NewReader(string(content)), dest, mode) + if err != nil { + return nil, err + } + + // Handle owner/group + if owner := getStringArg(args, "owner", ""); owner != "" { + client.Run(ctx, fmt.Sprintf("chown %s %q", owner, dest)) + } + if group := getStringArg(args, "group", ""); group != "" { + client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, dest)) + } + + return &TaskResult{Changed: true, Msg: fmt.Sprintf("copied to %s", dest)}, nil +} + +func (e *Executor) moduleTemplate(ctx context.Context, client *SSHClient, args map[string]any, host string, task *Task) (*TaskResult, error) { + src := getStringArg(args, "src", "") + dest := getStringArg(args, "dest", "") + if src == "" || dest == "" { + return nil, fmt.Errorf("template: src and dest required") + } + + // Process template + content, err := e.TemplateFile(src, host, task) + if err != nil { + return nil, fmt.Errorf("template: %w", err) + } + + mode := os.FileMode(0644) + if m := getStringArg(args, "mode", ""); m != "" { + if parsed, err := strconv.ParseInt(m, 8, 32); err == nil { + mode = os.FileMode(parsed) + } + } + + err = client.Upload(ctx, strings.NewReader(content), dest, mode) + if err != nil { + return nil, err + } + + return &TaskResult{Changed: true, Msg: fmt.Sprintf("templated to %s", dest)}, nil +} + +func (e *Executor) moduleFile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("file: path required") + } + + state := getStringArg(args, "state", "file") + + switch state { + case "directory": + mode := getStringArg(args, "mode", "0755") + cmd := fmt.Sprintf("mkdir -p %q && chmod %s %q", path, mode, path) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + case "absent": + cmd := fmt.Sprintf("rm -rf %q", path) + _, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "touch": + cmd := fmt.Sprintf("touch %q", path) + _, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "link": + src := getStringArg(args, "src", "") + if src == "" { + return nil, fmt.Errorf("file: src required for link state") + } + cmd := fmt.Sprintf("ln -sf %q %q", src, path) + _, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + + case "file": + // Ensure file exists and set permissions + if mode := getStringArg(args, "mode", ""); mode != "" { + client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, path)) + } + } + + // Handle owner/group + if owner := getStringArg(args, "owner", ""); owner != "" { + client.Run(ctx, fmt.Sprintf("chown %s %q", owner, path)) + } + if group := getStringArg(args, "group", ""); group != "" { + client.Run(ctx, fmt.Sprintf("chgrp %s %q", group, path)) + } + if recurse := getBoolArg(args, "recurse", false); recurse { + if owner := getStringArg(args, "owner", ""); owner != "" { + client.Run(ctx, fmt.Sprintf("chown -R %s %q", owner, path)) + } + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleLineinfile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("lineinfile: path required") + } + + line := getStringArg(args, "line", "") + regexp := getStringArg(args, "regexp", "") + state := getStringArg(args, "state", "present") + + if state == "absent" { + if regexp != "" { + cmd := fmt.Sprintf("sed -i '/%s/d' %q", regexp, path) + _, stderr, rc, _ := client.Run(ctx, cmd) + if rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, RC: rc}, nil + } + } + } else { + // state == present + if regexp != "" { + // Replace line matching regexp + escapedLine := strings.ReplaceAll(line, "/", "\\/") + cmd := fmt.Sprintf("sed -i 's/%s/%s/' %q", regexp, escapedLine, path) + _, _, rc, _ := client.Run(ctx, cmd) + if rc != 0 { + // Line not found, append + cmd = fmt.Sprintf("echo %q >> %q", line, path) + client.Run(ctx, cmd) + } + } else if line != "" { + // Ensure line is present + cmd := fmt.Sprintf("grep -qxF %q %q || echo %q >> %q", line, path, line, path) + client.Run(ctx, cmd) + } + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleStat(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + return nil, fmt.Errorf("stat: path required") + } + + stat, err := client.Stat(ctx, path) + if err != nil { + return nil, err + } + + return &TaskResult{ + Changed: false, + Data: map[string]any{"stat": stat}, + }, nil +} + +func (e *Executor) moduleSlurp(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "src", "") + } + if path == "" { + return nil, fmt.Errorf("slurp: path required") + } + + content, err := client.Download(ctx, path) + if err != nil { + return nil, err + } + + encoded := base64.StdEncoding.EncodeToString(content) + + return &TaskResult{ + Changed: false, + Data: map[string]any{"content": encoded, "encoding": "base64"}, + }, nil +} + +func (e *Executor) moduleFetch(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + src := getStringArg(args, "src", "") + dest := getStringArg(args, "dest", "") + if src == "" || dest == "" { + return nil, fmt.Errorf("fetch: src and dest required") + } + + content, err := client.Download(ctx, src) + if err != nil { + return nil, err + } + + // Create dest directory + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return nil, err + } + + if err := os.WriteFile(dest, content, 0644); err != nil { + return nil, err + } + + return &TaskResult{Changed: true, Msg: fmt.Sprintf("fetched %s to %s", src, dest)}, nil +} + +func (e *Executor) moduleGetURL(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + url := getStringArg(args, "url", "") + dest := getStringArg(args, "dest", "") + if url == "" || dest == "" { + return nil, fmt.Errorf("get_url: url and dest required") + } + + // Use curl or wget + cmd := fmt.Sprintf("curl -fsSL -o %q %q || wget -q -O %q %q", dest, url, dest, url) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + // Set mode if specified + if mode := getStringArg(args, "mode", ""); mode != "" { + client.Run(ctx, fmt.Sprintf("chmod %s %q", mode, dest)) + } + + return &TaskResult{Changed: true}, nil +} + +// --- Package Modules --- + +func (e *Executor) moduleApt(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + updateCache := getBoolArg(args, "update_cache", false) + + var cmd string + + if updateCache { + client.Run(ctx, "apt-get update -qq") + } + + switch state { + case "present", "installed": + if name != "" { + cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq %s", name) + } + case "absent", "removed": + cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get remove -y -qq %s", name) + case "latest": + cmd = fmt.Sprintf("DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade %s", name) + } + + if cmd == "" { + return &TaskResult{Changed: false}, nil + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleAptKey(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + url := getStringArg(args, "url", "") + keyring := getStringArg(args, "keyring", "") + state := getStringArg(args, "state", "present") + + if state == "absent" { + if keyring != "" { + client.Run(ctx, fmt.Sprintf("rm -f %q", keyring)) + } + return &TaskResult{Changed: true}, nil + } + + if url == "" { + return nil, fmt.Errorf("apt_key: url required") + } + + var cmd string + if keyring != "" { + cmd = fmt.Sprintf("curl -fsSL %q | gpg --dearmor -o %q", url, keyring) + } else { + cmd = fmt.Sprintf("curl -fsSL %q | apt-key add -", url) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleAptRepository(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + repo := getStringArg(args, "repo", "") + filename := getStringArg(args, "filename", "") + state := getStringArg(args, "state", "present") + + if repo == "" { + return nil, fmt.Errorf("apt_repository: repo required") + } + + if filename == "" { + // Generate filename from repo + filename = strings.ReplaceAll(repo, " ", "-") + filename = strings.ReplaceAll(filename, "/", "-") + filename = strings.ReplaceAll(filename, ":", "") + } + + path := fmt.Sprintf("/etc/apt/sources.list.d/%s.list", filename) + + if state == "absent" { + client.Run(ctx, fmt.Sprintf("rm -f %q", path)) + return &TaskResult{Changed: true}, nil + } + + cmd := fmt.Sprintf("echo %q > %q", repo, path) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + // Update apt cache + if getBoolArg(args, "update_cache", true) { + client.Run(ctx, "apt-get update -qq") + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) modulePackage(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + // Detect package manager and delegate + stdout, _, _, _ := client.Run(ctx, "which apt-get yum dnf 2>/dev/null | head -1") + stdout = strings.TrimSpace(stdout) + + if strings.Contains(stdout, "apt") { + return e.moduleApt(ctx, client, args) + } + + // Default to apt + return e.moduleApt(ctx, client, args) +} + +func (e *Executor) modulePip(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + executable := getStringArg(args, "executable", "pip3") + + var cmd string + switch state { + case "present", "installed": + cmd = fmt.Sprintf("%s install %s", executable, name) + case "absent", "removed": + cmd = fmt.Sprintf("%s uninstall -y %s", executable, name) + case "latest": + cmd = fmt.Sprintf("%s install --upgrade %s", executable, name) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +// --- Service Modules --- + +func (e *Executor) moduleService(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "") + enabled := args["enabled"] + + if name == "" { + return nil, fmt.Errorf("service: name required") + } + + var cmds []string + + if state != "" { + switch state { + case "started": + cmds = append(cmds, fmt.Sprintf("systemctl start %s", name)) + case "stopped": + cmds = append(cmds, fmt.Sprintf("systemctl stop %s", name)) + case "restarted": + cmds = append(cmds, fmt.Sprintf("systemctl restart %s", name)) + case "reloaded": + cmds = append(cmds, fmt.Sprintf("systemctl reload %s", name)) + } + } + + if enabled != nil { + if getBoolArg(args, "enabled", false) { + cmds = append(cmds, fmt.Sprintf("systemctl enable %s", name)) + } else { + cmds = append(cmds, fmt.Sprintf("systemctl disable %s", name)) + } + } + + for _, cmd := range cmds { + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + } + + return &TaskResult{Changed: len(cmds) > 0}, nil +} + +func (e *Executor) moduleSystemd(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + // systemd is similar to service + if getBoolArg(args, "daemon_reload", false) { + client.Run(ctx, "systemctl daemon-reload") + } + + return e.moduleService(ctx, client, args) +} + +// --- User/Group Modules --- + +func (e *Executor) moduleUser(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + + if name == "" { + return nil, fmt.Errorf("user: name required") + } + + if state == "absent" { + cmd := fmt.Sprintf("userdel -r %s 2>/dev/null || true", name) + client.Run(ctx, cmd) + return &TaskResult{Changed: true}, nil + } + + // Build useradd/usermod command + var opts []string + + if uid := getStringArg(args, "uid", ""); uid != "" { + opts = append(opts, "-u", uid) + } + if group := getStringArg(args, "group", ""); group != "" { + opts = append(opts, "-g", group) + } + if groups := getStringArg(args, "groups", ""); groups != "" { + opts = append(opts, "-G", groups) + } + if home := getStringArg(args, "home", ""); home != "" { + opts = append(opts, "-d", home) + } + if shell := getStringArg(args, "shell", ""); shell != "" { + opts = append(opts, "-s", shell) + } + if getBoolArg(args, "system", false) { + opts = append(opts, "-r") + } + if getBoolArg(args, "create_home", true) { + opts = append(opts, "-m") + } + + // Try usermod first, then useradd + cmd := fmt.Sprintf("id %s >/dev/null 2>&1 && usermod %s %s || useradd %s %s", + name, strings.Join(opts, " "), name, strings.Join(opts, " "), name) + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleGroup(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + state := getStringArg(args, "state", "present") + + if name == "" { + return nil, fmt.Errorf("group: name required") + } + + if state == "absent" { + cmd := fmt.Sprintf("groupdel %s 2>/dev/null || true", name) + client.Run(ctx, cmd) + return &TaskResult{Changed: true}, nil + } + + var opts []string + if gid := getStringArg(args, "gid", ""); gid != "" { + opts = append(opts, "-g", gid) + } + if getBoolArg(args, "system", false) { + opts = append(opts, "-r") + } + + cmd := fmt.Sprintf("getent group %s >/dev/null 2>&1 || groupadd %s %s", + name, strings.Join(opts, " "), name) + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +// --- HTTP Module --- + +func (e *Executor) moduleURI(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + url := getStringArg(args, "url", "") + method := getStringArg(args, "method", "GET") + + if url == "" { + return nil, fmt.Errorf("uri: url required") + } + + var curlOpts []string + curlOpts = append(curlOpts, "-s", "-S") + curlOpts = append(curlOpts, "-X", method) + + // Headers + if headers, ok := args["headers"].(map[string]any); ok { + for k, v := range headers { + curlOpts = append(curlOpts, "-H", fmt.Sprintf("%s: %v", k, v)) + } + } + + // Body + if body := getStringArg(args, "body", ""); body != "" { + curlOpts = append(curlOpts, "-d", body) + } + + // Status code + curlOpts = append(curlOpts, "-w", "\\n%{http_code}") + + cmd := fmt.Sprintf("curl %s %q", strings.Join(curlOpts, " "), url) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil { + return &TaskResult{Failed: true, Msg: err.Error()}, nil + } + + // Parse status code from last line + lines := strings.Split(strings.TrimSpace(stdout), "\n") + statusCode := 0 + if len(lines) > 0 { + statusCode, _ = strconv.Atoi(lines[len(lines)-1]) + } + + // Check expected status + expectedStatus := 200 + if s, ok := args["status_code"].(int); ok { + expectedStatus = s + } + + failed := rc != 0 || statusCode != expectedStatus + + return &TaskResult{ + Changed: false, + Failed: failed, + Stdout: stdout, + Stderr: stderr, + RC: statusCode, + Data: map[string]any{"status": statusCode}, + }, nil +} + +// --- Misc Modules --- + +func (e *Executor) moduleDebug(args map[string]any) (*TaskResult, error) { + msg := getStringArg(args, "msg", "") + if v, ok := args["var"]; ok { + msg = fmt.Sprintf("%v = %v", v, e.vars[fmt.Sprintf("%v", v)]) + } + + return &TaskResult{ + Changed: false, + Msg: msg, + }, nil +} + +func (e *Executor) moduleFail(args map[string]any) (*TaskResult, error) { + msg := getStringArg(args, "msg", "Failed as requested") + return &TaskResult{ + Failed: true, + Msg: msg, + }, nil +} + +func (e *Executor) moduleAssert(args map[string]any, host string) (*TaskResult, error) { + that, ok := args["that"] + if !ok { + return nil, fmt.Errorf("assert: 'that' required") + } + + conditions := normalizeConditions(that) + for _, cond := range conditions { + if !e.evalCondition(cond, host) { + msg := getStringArg(args, "fail_msg", fmt.Sprintf("Assertion failed: %s", cond)) + return &TaskResult{Failed: true, Msg: msg}, nil + } + } + + return &TaskResult{Changed: false, Msg: "All assertions passed"}, nil +} + +func (e *Executor) moduleSetFact(args map[string]any) (*TaskResult, error) { + for k, v := range args { + if k != "cacheable" { + e.vars[k] = v + } + } + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) modulePause(ctx context.Context, args map[string]any) (*TaskResult, error) { + seconds := 0 + if s, ok := args["seconds"].(int); ok { + seconds = s + } + if s, ok := args["seconds"].(string); ok { + seconds, _ = strconv.Atoi(s) + } + + if seconds > 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ctxSleep(ctx, seconds): + } + } + + return &TaskResult{Changed: false}, nil +} + +func ctxSleep(ctx context.Context, seconds int) <-chan struct{} { + ch := make(chan struct{}) + go func() { + select { + case <-ctx.Done(): + case <-sleepChan(seconds): + } + close(ch) + }() + return ch +} + +func sleepChan(seconds int) <-chan struct{} { + ch := make(chan struct{}) + go func() { + for i := 0; i < seconds; i++ { + select { + case <-ch: + return + default: + // Sleep 1 second at a time + } + } + close(ch) + }() + return ch +} + +func (e *Executor) moduleWaitFor(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + port := 0 + if p, ok := args["port"].(int); ok { + port = p + } + host := getStringArg(args, "host", "127.0.0.1") + state := getStringArg(args, "state", "started") + timeout := 300 + if t, ok := args["timeout"].(int); ok { + timeout = t + } + + if port > 0 && state == "started" { + cmd := fmt.Sprintf("timeout %d bash -c 'until nc -z %s %d; do sleep 1; done'", + timeout, host, port) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + } + + return &TaskResult{Changed: false}, nil +} + +func (e *Executor) moduleGit(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + repo := getStringArg(args, "repo", "") + dest := getStringArg(args, "dest", "") + version := getStringArg(args, "version", "HEAD") + + if repo == "" || dest == "" { + return nil, fmt.Errorf("git: repo and dest required") + } + + // Check if dest exists + exists, _ := client.FileExists(ctx, dest+"/.git") + + var cmd string + if exists { + cmd = fmt.Sprintf("cd %q && git fetch --all && git checkout %s && git pull origin %s 2>/dev/null || true", + dest, version, version) + } else { + cmd = fmt.Sprintf("git clone %q %q && cd %q && git checkout %s", + repo, dest, dest, version) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleUnarchive(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + src := getStringArg(args, "src", "") + dest := getStringArg(args, "dest", "") + remote := getBoolArg(args, "remote_src", false) + + if src == "" || dest == "" { + return nil, fmt.Errorf("unarchive: src and dest required") + } + + // Create dest directory + client.Run(ctx, fmt.Sprintf("mkdir -p %q", dest)) + + var cmd string + if !remote { + // Upload local file first + content, err := os.ReadFile(src) + if err != nil { + return nil, fmt.Errorf("read src: %w", err) + } + tmpPath := "/tmp/ansible_unarchive_" + filepath.Base(src) + err = client.Upload(ctx, strings.NewReader(string(content)), tmpPath, 0644) + if err != nil { + return nil, err + } + src = tmpPath + defer client.Run(ctx, fmt.Sprintf("rm -f %q", tmpPath)) + } + + // Detect archive type and extract + if strings.HasSuffix(src, ".tar.gz") || strings.HasSuffix(src, ".tgz") { + cmd = fmt.Sprintf("tar -xzf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar.xz") { + cmd = fmt.Sprintf("tar -xJf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar.bz2") { + cmd = fmt.Sprintf("tar -xjf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".tar") { + cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) + } else if strings.HasSuffix(src, ".zip") { + cmd = fmt.Sprintf("unzip -o %q -d %q", src, dest) + } else { + cmd = fmt.Sprintf("tar -xf %q -C %q", src, dest) // Guess tar + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +// --- Helpers --- + +func getStringArg(args map[string]any, key, def string) string { + if v, ok := args[key]; ok { + if s, ok := v.(string); ok { + return s + } + return fmt.Sprintf("%v", v) + } + return def +} + +func getBoolArg(args map[string]any, key string, def bool) bool { + if v, ok := args[key]; ok { + switch b := v.(type) { + case bool: + return b + case string: + lower := strings.ToLower(b) + return lower == "true" || lower == "yes" || lower == "1" + } + } + return def +} + +// --- Additional Modules --- + +func (e *Executor) moduleHostname(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + if name == "" { + return nil, fmt.Errorf("hostname: name required") + } + + // Set hostname + cmd := fmt.Sprintf("hostnamectl set-hostname %q || hostname %q", name, name) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + // Update /etc/hosts if needed + client.Run(ctx, fmt.Sprintf("sed -i 's/127.0.1.1.*/127.0.1.1\t%s/' /etc/hosts", name)) + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleSysctl(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + value := getStringArg(args, "value", "") + state := getStringArg(args, "state", "present") + + if name == "" { + return nil, fmt.Errorf("sysctl: name required") + } + + if state == "absent" { + // Remove from sysctl.conf + cmd := fmt.Sprintf("sed -i '/%s/d' /etc/sysctl.conf", name) + client.Run(ctx, cmd) + return &TaskResult{Changed: true}, nil + } + + // Set value + cmd := fmt.Sprintf("sysctl -w %s=%s", name, value) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + // Persist if requested + if getBoolArg(args, "sysctl_set", true) { + cmd = fmt.Sprintf("grep -q '^%s' /etc/sysctl.conf && sed -i 's/^%s.*/%s=%s/' /etc/sysctl.conf || echo '%s=%s' >> /etc/sysctl.conf", + name, name, name, value, name, value) + client.Run(ctx, cmd) + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleCron(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + name := getStringArg(args, "name", "") + job := getStringArg(args, "job", "") + state := getStringArg(args, "state", "present") + user := getStringArg(args, "user", "root") + + minute := getStringArg(args, "minute", "*") + hour := getStringArg(args, "hour", "*") + day := getStringArg(args, "day", "*") + month := getStringArg(args, "month", "*") + weekday := getStringArg(args, "weekday", "*") + + if state == "absent" { + if name != "" { + // Remove by name (comment marker) + cmd := fmt.Sprintf("crontab -u %s -l 2>/dev/null | grep -v '# %s' | grep -v '%s' | crontab -u %s -", + user, name, job, user) + client.Run(ctx, cmd) + } + return &TaskResult{Changed: true}, nil + } + + // Build cron entry + schedule := fmt.Sprintf("%s %s %s %s %s", minute, hour, day, month, weekday) + entry := fmt.Sprintf("%s %s # %s", schedule, job, name) + + // Add to crontab + cmd := fmt.Sprintf("(crontab -u %s -l 2>/dev/null | grep -v '# %s' ; echo %q) | crontab -u %s -", + user, name, entry, user) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleBlockinfile(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + path := getStringArg(args, "path", "") + if path == "" { + path = getStringArg(args, "dest", "") + } + if path == "" { + return nil, fmt.Errorf("blockinfile: path required") + } + + block := getStringArg(args, "block", "") + marker := getStringArg(args, "marker", "# {mark} ANSIBLE MANAGED BLOCK") + state := getStringArg(args, "state", "present") + create := getBoolArg(args, "create", false) + + beginMarker := strings.Replace(marker, "{mark}", "BEGIN", 1) + endMarker := strings.Replace(marker, "{mark}", "END", 1) + + if state == "absent" { + // Remove block + cmd := fmt.Sprintf("sed -i '/%s/,/%s/d' %q", + strings.ReplaceAll(beginMarker, "/", "\\/"), + strings.ReplaceAll(endMarker, "/", "\\/"), + path) + client.Run(ctx, cmd) + return &TaskResult{Changed: true}, nil + } + + // Create file if needed + if create { + client.Run(ctx, fmt.Sprintf("touch %q", path)) + } + + // Remove existing block and add new one + escapedBlock := strings.ReplaceAll(block, "'", "'\\''") + cmd := fmt.Sprintf(` +sed -i '/%s/,/%s/d' %q 2>/dev/null || true +cat >> %q << 'BLOCK_EOF' +%s +%s +%s +BLOCK_EOF +`, strings.ReplaceAll(beginMarker, "/", "\\/"), + strings.ReplaceAll(endMarker, "/", "\\/"), + path, path, beginMarker, escapedBlock, endMarker) + + stdout, stderr, rc, err := client.RunScript(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleIncludeVars(args map[string]any) (*TaskResult, error) { + file := getStringArg(args, "file", "") + if file == "" { + file = getStringArg(args, "_raw_params", "") + } + + if file != "" { + // Would need to read and parse the vars file + // For now, just acknowledge + return &TaskResult{Changed: false, Msg: "include_vars: " + file}, nil + } + + return &TaskResult{Changed: false}, nil +} + +func (e *Executor) moduleMeta(args map[string]any) (*TaskResult, error) { + // meta module controls play execution + // Most actions are no-ops for us + return &TaskResult{Changed: false}, nil +} + +func (e *Executor) moduleSetup(ctx context.Context, client *SSHClient) (*TaskResult, error) { + // Gather facts - similar to what we do in gatherFacts + return &TaskResult{Changed: false, Msg: "facts gathered"}, nil +} + +func (e *Executor) moduleReboot(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + preRebootDelay := 0 + if d, ok := args["pre_reboot_delay"].(int); ok { + preRebootDelay = d + } + + msg := getStringArg(args, "msg", "Reboot initiated by Ansible") + + if preRebootDelay > 0 { + cmd := fmt.Sprintf("sleep %d && shutdown -r now '%s' &", preRebootDelay, msg) + client.Run(ctx, cmd) + } else { + client.Run(ctx, fmt.Sprintf("shutdown -r now '%s' &", msg)) + } + + return &TaskResult{Changed: true, Msg: "Reboot initiated"}, nil +} + +func (e *Executor) moduleUFW(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + rule := getStringArg(args, "rule", "") + port := getStringArg(args, "port", "") + proto := getStringArg(args, "proto", "tcp") + state := getStringArg(args, "state", "") + + var cmd string + + // Handle state (enable/disable) + if state != "" { + switch state { + case "enabled": + cmd = "ufw --force enable" + case "disabled": + cmd = "ufw disable" + case "reloaded": + cmd = "ufw reload" + case "reset": + cmd = "ufw --force reset" + } + if cmd != "" { + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + return &TaskResult{Changed: true}, nil + } + } + + // Handle rule + if rule != "" && port != "" { + switch rule { + case "allow": + cmd = fmt.Sprintf("ufw allow %s/%s", port, proto) + case "deny": + cmd = fmt.Sprintf("ufw deny %s/%s", port, proto) + case "reject": + cmd = fmt.Sprintf("ufw reject %s/%s", port, proto) + case "limit": + cmd = fmt.Sprintf("ufw limit %s/%s", port, proto) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + } + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleAuthorizedKey(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + user := getStringArg(args, "user", "") + key := getStringArg(args, "key", "") + state := getStringArg(args, "state", "present") + + if user == "" || key == "" { + return nil, fmt.Errorf("authorized_key: user and key required") + } + + // Get user's home directory + stdout, _, _, err := client.Run(ctx, fmt.Sprintf("getent passwd %s | cut -d: -f6", user)) + if err != nil { + return nil, fmt.Errorf("get home dir: %w", err) + } + home := strings.TrimSpace(stdout) + if home == "" { + home = "/root" + if user != "root" { + home = "/home/" + user + } + } + + authKeysPath := filepath.Join(home, ".ssh", "authorized_keys") + + if state == "absent" { + // Remove key + escapedKey := strings.ReplaceAll(key, "/", "\\/") + cmd := fmt.Sprintf("sed -i '/%s/d' %q 2>/dev/null || true", escapedKey[:40], authKeysPath) + client.Run(ctx, cmd) + return &TaskResult{Changed: true}, nil + } + + // Ensure .ssh directory exists + client.Run(ctx, fmt.Sprintf("mkdir -p %q && chmod 700 %q && chown %s:%s %q", + filepath.Dir(authKeysPath), filepath.Dir(authKeysPath), user, user, filepath.Dir(authKeysPath))) + + // Add key if not present + cmd := fmt.Sprintf("grep -qF %q %q 2>/dev/null || echo %q >> %q", + key[:40], authKeysPath, key, authKeysPath) + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + // Fix permissions + client.Run(ctx, fmt.Sprintf("chmod 600 %q && chown %s:%s %q", + authKeysPath, user, user, authKeysPath)) + + return &TaskResult{Changed: true}, nil +} + +func (e *Executor) moduleDockerCompose(ctx context.Context, client *SSHClient, args map[string]any) (*TaskResult, error) { + projectSrc := getStringArg(args, "project_src", "") + state := getStringArg(args, "state", "present") + + if projectSrc == "" { + return nil, fmt.Errorf("docker_compose: project_src required") + } + + var cmd string + switch state { + case "present": + cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc) + case "absent": + cmd = fmt.Sprintf("cd %q && docker compose down", projectSrc) + case "restarted": + cmd = fmt.Sprintf("cd %q && docker compose restart", projectSrc) + default: + cmd = fmt.Sprintf("cd %q && docker compose up -d", projectSrc) + } + + stdout, stderr, rc, err := client.Run(ctx, cmd) + if err != nil || rc != 0 { + return &TaskResult{Failed: true, Msg: stderr, Stdout: stdout, RC: rc}, nil + } + + return &TaskResult{Changed: true, Stdout: stdout}, nil +} diff --git a/pkg/ansible/parser.go b/pkg/ansible/parser.go new file mode 100644 index 00000000..49520002 --- /dev/null +++ b/pkg/ansible/parser.go @@ -0,0 +1,437 @@ +package ansible + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// Parser handles Ansible YAML parsing. +type Parser struct { + basePath string + vars map[string]any +} + +// NewParser creates a new Ansible parser. +func NewParser(basePath string) *Parser { + return &Parser{ + basePath: basePath, + vars: make(map[string]any), + } +} + +// ParsePlaybook parses an Ansible playbook file. +func (p *Parser) ParsePlaybook(path string) ([]Play, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read playbook: %w", err) + } + + var plays []Play + if err := yaml.Unmarshal(data, &plays); err != nil { + return nil, fmt.Errorf("parse playbook: %w", err) + } + + // Process each play + for i := range plays { + if err := p.processPlay(&plays[i]); err != nil { + return nil, fmt.Errorf("process play %d: %w", i, err) + } + } + + return plays, nil +} + +// ParseInventory parses an Ansible inventory file. +func (p *Parser) ParseInventory(path string) (*Inventory, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read inventory: %w", err) + } + + var inv Inventory + if err := yaml.Unmarshal(data, &inv); err != nil { + return nil, fmt.Errorf("parse inventory: %w", err) + } + + return &inv, nil +} + +// ParseTasks parses a tasks file (used by include_tasks). +func (p *Parser) ParseTasks(path string) ([]Task, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read tasks: %w", err) + } + + var tasks []Task + if err := yaml.Unmarshal(data, &tasks); err != nil { + return nil, fmt.Errorf("parse tasks: %w", err) + } + + for i := range tasks { + if err := p.extractModule(&tasks[i]); err != nil { + return nil, fmt.Errorf("task %d: %w", i, err) + } + } + + return tasks, nil +} + +// ParseRole parses a role and returns its tasks. +func (p *Parser) ParseRole(name string, tasksFrom string) ([]Task, error) { + if tasksFrom == "" { + tasksFrom = "main.yml" + } + + // Search paths for roles (in order of precedence) + searchPaths := []string{ + // Relative to playbook + filepath.Join(p.basePath, "roles", name, "tasks", tasksFrom), + // Parent directory roles + filepath.Join(filepath.Dir(p.basePath), "roles", name, "tasks", tasksFrom), + // Sibling roles directory + filepath.Join(p.basePath, "..", "roles", name, "tasks", tasksFrom), + // playbooks/roles pattern + filepath.Join(p.basePath, "playbooks", "roles", name, "tasks", tasksFrom), + // Common DevOps structure + filepath.Join(filepath.Dir(filepath.Dir(p.basePath)), "roles", name, "tasks", tasksFrom), + } + + var tasksPath string + for _, sp := range searchPaths { + // Clean the path to resolve .. segments + sp = filepath.Clean(sp) + if _, err := os.Stat(sp); err == nil { + tasksPath = sp + break + } + } + + if tasksPath == "" { + return nil, fmt.Errorf("role %s not found in search paths: %v", name, searchPaths) + } + + // Load role defaults + defaultsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "defaults", "main.yml") + if data, err := os.ReadFile(defaultsPath); err == nil { + var defaults map[string]any + if yaml.Unmarshal(data, &defaults) == nil { + for k, v := range defaults { + if _, exists := p.vars[k]; !exists { + p.vars[k] = v + } + } + } + } + + // Load role vars + varsPath := filepath.Join(filepath.Dir(filepath.Dir(tasksPath)), "vars", "main.yml") + if data, err := os.ReadFile(varsPath); err == nil { + var roleVars map[string]any + if yaml.Unmarshal(data, &roleVars) == nil { + for k, v := range roleVars { + p.vars[k] = v + } + } + } + + return p.ParseTasks(tasksPath) +} + +// processPlay processes a play and extracts modules from tasks. +func (p *Parser) processPlay(play *Play) error { + // Merge play vars + for k, v := range play.Vars { + p.vars[k] = v + } + + for i := range play.PreTasks { + if err := p.extractModule(&play.PreTasks[i]); err != nil { + return fmt.Errorf("pre_task %d: %w", i, err) + } + } + + for i := range play.Tasks { + if err := p.extractModule(&play.Tasks[i]); err != nil { + return fmt.Errorf("task %d: %w", i, err) + } + } + + for i := range play.PostTasks { + if err := p.extractModule(&play.PostTasks[i]); err != nil { + return fmt.Errorf("post_task %d: %w", i, err) + } + } + + for i := range play.Handlers { + if err := p.extractModule(&play.Handlers[i]); err != nil { + return fmt.Errorf("handler %d: %w", i, err) + } + } + + return nil +} + +// extractModule extracts the module name and args from a task. +func (p *Parser) extractModule(task *Task) error { + // First, unmarshal the raw YAML to get all keys + // This is a workaround since we need to find the module key dynamically + + // Handle block tasks + for i := range task.Block { + if err := p.extractModule(&task.Block[i]); err != nil { + return err + } + } + for i := range task.Rescue { + if err := p.extractModule(&task.Rescue[i]); err != nil { + return err + } + } + for i := range task.Always { + if err := p.extractModule(&task.Always[i]); err != nil { + return err + } + } + + return nil +} + +// UnmarshalYAML implements custom YAML unmarshaling for Task. +func (t *Task) UnmarshalYAML(node *yaml.Node) error { + // First decode known fields + type rawTask Task + var raw rawTask + + // Create a map to capture all fields + var m map[string]any + if err := node.Decode(&m); err != nil { + return err + } + + // Decode into struct + if err := node.Decode(&raw); err != nil { + return err + } + *t = Task(raw) + t.raw = m + + // Find the module key + knownKeys := map[string]bool{ + "name": true, "register": true, "when": true, "loop": true, + "loop_control": true, "vars": true, "environment": true, + "changed_when": true, "failed_when": true, "ignore_errors": true, + "no_log": true, "become": true, "become_user": true, + "delegate_to": true, "run_once": true, "tags": true, + "block": true, "rescue": true, "always": true, "notify": true, + "retries": true, "delay": true, "until": true, + "include_tasks": true, "import_tasks": true, + "include_role": true, "import_role": true, + "with_items": true, "with_dict": true, "with_file": true, + } + + for key, val := range m { + if knownKeys[key] { + continue + } + + // Check if this is a module + if isModule(key) { + t.Module = key + t.Args = make(map[string]any) + + switch v := val.(type) { + case string: + // Free-form args (e.g., shell: echo hello) + t.Args["_raw_params"] = v + case map[string]any: + t.Args = v + case nil: + // Module with no args + default: + t.Args["_raw_params"] = v + } + break + } + } + + // Handle with_items as loop + if items, ok := m["with_items"]; ok && t.Loop == nil { + t.Loop = items + } + + return nil +} + +// isModule checks if a key is a known module. +func isModule(key string) bool { + for _, m := range KnownModules { + if key == m { + return true + } + // Also check without ansible.builtin. prefix + if strings.HasPrefix(m, "ansible.builtin.") { + if key == strings.TrimPrefix(m, "ansible.builtin.") { + return true + } + } + } + // Accept any key with dots (likely a module) + return strings.Contains(key, ".") +} + +// NormalizeModule normalizes a module name to its canonical form. +func NormalizeModule(name string) string { + // Add ansible.builtin. prefix if missing + if !strings.Contains(name, ".") { + return "ansible.builtin." + name + } + return name +} + +// GetHosts returns hosts matching a pattern from inventory. +func GetHosts(inv *Inventory, pattern string) []string { + if pattern == "all" { + return getAllHosts(inv.All) + } + if pattern == "localhost" { + return []string{"localhost"} + } + + // Check if it's a group name + hosts := getGroupHosts(inv.All, pattern) + if len(hosts) > 0 { + return hosts + } + + // Check if it's a specific host + if hasHost(inv.All, pattern) { + return []string{pattern} + } + + // Handle patterns with : (intersection/union) + // For now, just return empty + return nil +} + +func getAllHosts(group *InventoryGroup) []string { + if group == nil { + return nil + } + + var hosts []string + for name := range group.Hosts { + hosts = append(hosts, name) + } + for _, child := range group.Children { + hosts = append(hosts, getAllHosts(child)...) + } + return hosts +} + +func getGroupHosts(group *InventoryGroup, name string) []string { + if group == nil { + return nil + } + + // Check children for the group name + if child, ok := group.Children[name]; ok { + return getAllHosts(child) + } + + // Recurse + for _, child := range group.Children { + if hosts := getGroupHosts(child, name); len(hosts) > 0 { + return hosts + } + } + + return nil +} + +func hasHost(group *InventoryGroup, name string) bool { + if group == nil { + return false + } + + if _, ok := group.Hosts[name]; ok { + return true + } + + for _, child := range group.Children { + if hasHost(child, name) { + return true + } + } + + return false +} + +// GetHostVars returns variables for a specific host. +func GetHostVars(inv *Inventory, hostname string) map[string]any { + vars := make(map[string]any) + + // Collect vars from all levels + collectHostVars(inv.All, hostname, vars) + + return vars +} + +func collectHostVars(group *InventoryGroup, hostname string, vars map[string]any) bool { + if group == nil { + return false + } + + // Check if host is in this group + found := false + if host, ok := group.Hosts[hostname]; ok { + found = true + // Apply group vars first + for k, v := range group.Vars { + vars[k] = v + } + // Then host vars + if host != nil { + if host.AnsibleHost != "" { + vars["ansible_host"] = host.AnsibleHost + } + if host.AnsiblePort != 0 { + vars["ansible_port"] = host.AnsiblePort + } + if host.AnsibleUser != "" { + vars["ansible_user"] = host.AnsibleUser + } + if host.AnsiblePassword != "" { + vars["ansible_password"] = host.AnsiblePassword + } + if host.AnsibleSSHPrivateKeyFile != "" { + vars["ansible_ssh_private_key_file"] = host.AnsibleSSHPrivateKeyFile + } + if host.AnsibleConnection != "" { + vars["ansible_connection"] = host.AnsibleConnection + } + for k, v := range host.Vars { + vars[k] = v + } + } + } + + // Check children + for _, child := range group.Children { + if collectHostVars(child, hostname, vars) { + // Apply this group's vars (parent vars) + for k, v := range group.Vars { + if _, exists := vars[k]; !exists { + vars[k] = v + } + } + found = true + } + } + + return found +} diff --git a/pkg/ansible/ssh.go b/pkg/ansible/ssh.go new file mode 100644 index 00000000..51c57a2c --- /dev/null +++ b/pkg/ansible/ssh.go @@ -0,0 +1,384 @@ +package ansible + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// SSHClient handles SSH connections to remote hosts. +type SSHClient struct { + host string + port int + user string + password string + keyFile string + client *ssh.Client + mu sync.Mutex + become bool + becomeUser string + becomePass string +} + +// SSHConfig holds SSH connection configuration. +type SSHConfig struct { + Host string + Port int + User string + Password string + KeyFile string + Become bool + BecomeUser string + BecomePass string + Timeout time.Duration +} + +// NewSSHClient creates a new SSH client. +func NewSSHClient(cfg SSHConfig) (*SSHClient, error) { + if cfg.Port == 0 { + cfg.Port = 22 + } + if cfg.User == "" { + cfg.User = "root" + } + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + + client := &SSHClient{ + host: cfg.Host, + port: cfg.Port, + user: cfg.User, + password: cfg.Password, + keyFile: cfg.KeyFile, + become: cfg.Become, + becomeUser: cfg.BecomeUser, + becomePass: cfg.BecomePass, + } + + return client, nil +} + +// Connect establishes the SSH connection. +func (c *SSHClient) Connect(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + return nil + } + + var authMethods []ssh.AuthMethod + + // Try key-based auth first + if c.keyFile != "" { + keyPath := c.keyFile + if strings.HasPrefix(keyPath, "~") { + home, _ := os.UserHomeDir() + keyPath = filepath.Join(home, keyPath[1:]) + } + + if key, err := os.ReadFile(keyPath); err == nil { + if signer, err := ssh.ParsePrivateKey(key); err == nil { + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } + } + } + + // Try default SSH keys + if len(authMethods) == 0 { + home, _ := os.UserHomeDir() + defaultKeys := []string{ + filepath.Join(home, ".ssh", "id_ed25519"), + filepath.Join(home, ".ssh", "id_rsa"), + } + for _, keyPath := range defaultKeys { + if key, err := os.ReadFile(keyPath); err == nil { + if signer, err := ssh.ParsePrivateKey(key); err == nil { + authMethods = append(authMethods, ssh.PublicKeys(signer)) + break + } + } + } + } + + // Fall back to password auth + if c.password != "" { + authMethods = append(authMethods, ssh.Password(c.password)) + authMethods = append(authMethods, ssh.KeyboardInteractive(func(user, instruction string, questions []string, echos []bool) ([]string, error) { + answers := make([]string, len(questions)) + for i := range questions { + answers[i] = c.password + } + return answers, nil + })) + } + + if len(authMethods) == 0 { + return fmt.Errorf("no authentication method available") + } + + config := &ssh.ClientConfig{ + User: c.user, + Auth: authMethods, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO: proper host key checking + Timeout: 30 * time.Second, + } + + addr := fmt.Sprintf("%s:%d", c.host, c.port) + + // Connect with context timeout + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("dial %s: %w", addr, err) + } + + sshConn, chans, reqs, err := ssh.NewClientConn(conn, addr, config) + if err != nil { + conn.Close() + return fmt.Errorf("ssh connect %s: %w", addr, err) + } + + c.client = ssh.NewClient(sshConn, chans, reqs) + return nil +} + +// Close closes the SSH connection. +func (c *SSHClient) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + err := c.client.Close() + c.client = nil + return err + } + return nil +} + +// Run executes a command on the remote host. +func (c *SSHClient) Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error) { + if err := c.Connect(ctx); err != nil { + return "", "", -1, err + } + + session, err := c.client.NewSession() + if err != nil { + return "", "", -1, fmt.Errorf("new session: %w", err) + } + defer session.Close() + + var stdoutBuf, stderrBuf bytes.Buffer + session.Stdout = &stdoutBuf + session.Stderr = &stderrBuf + + // Apply become if needed + if c.become { + becomeUser := c.becomeUser + if becomeUser == "" { + becomeUser = "root" + } + // Escape single quotes in the command + escapedCmd := strings.ReplaceAll(cmd, "'", "'\\''") + if c.becomePass != "" { + // Use sudo with password via stdin (-S flag) + cmd = fmt.Sprintf("echo '%s' | sudo -S -u %s bash -c '%s'", c.becomePass, becomeUser, escapedCmd) + } else if c.password != "" { + // Try using connection password for sudo + cmd = fmt.Sprintf("echo '%s' | sudo -S -u %s bash -c '%s'", c.password, becomeUser, escapedCmd) + } else { + // Try passwordless sudo + cmd = fmt.Sprintf("sudo -n -u %s bash -c '%s'", becomeUser, escapedCmd) + } + } + + // Run with context + done := make(chan error, 1) + go func() { + done <- session.Run(cmd) + }() + + select { + case <-ctx.Done(): + session.Signal(ssh.SIGKILL) + return "", "", -1, ctx.Err() + case err := <-done: + exitCode = 0 + if err != nil { + if exitErr, ok := err.(*ssh.ExitError); ok { + exitCode = exitErr.ExitStatus() + } else { + return stdoutBuf.String(), stderrBuf.String(), -1, err + } + } + return stdoutBuf.String(), stderrBuf.String(), exitCode, nil + } +} + +// RunScript runs a script on the remote host. +func (c *SSHClient) RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error) { + // Escape the script for heredoc + cmd := fmt.Sprintf("bash <<'ANSIBLE_SCRIPT_EOF'\n%s\nANSIBLE_SCRIPT_EOF", script) + return c.Run(ctx, cmd) +} + +// Upload copies a file to the remote host. +func (c *SSHClient) Upload(ctx context.Context, local io.Reader, remote string, mode os.FileMode) error { + if err := c.Connect(ctx); err != nil { + return err + } + + session, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("new session: %w", err) + } + defer session.Close() + + // Read content + content, err := io.ReadAll(local) + if err != nil { + return fmt.Errorf("read content: %w", err) + } + + // Create parent directory + dir := filepath.Dir(remote) + dirCmd := fmt.Sprintf("mkdir -p %q", dir) + if c.become { + dirCmd = fmt.Sprintf("sudo mkdir -p %q", dir) + } + if _, _, _, err := c.Run(ctx, dirCmd); err != nil { + return fmt.Errorf("create parent dir: %w", err) + } + + // Use cat to write the file (simpler than SCP) + writeCmd := fmt.Sprintf("cat > %q && chmod %o %q", remote, mode, remote) + if c.become { + writeCmd = fmt.Sprintf("sudo bash -c 'cat > %q && chmod %o %q'", remote, mode, remote) + } + + session2, err := c.client.NewSession() + if err != nil { + return fmt.Errorf("new session for write: %w", err) + } + defer session2.Close() + + stdin, err := session2.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + var stderrBuf bytes.Buffer + session2.Stderr = &stderrBuf + + if err := session2.Start(writeCmd); err != nil { + return fmt.Errorf("start write: %w", err) + } + + if _, err := stdin.Write(content); err != nil { + return fmt.Errorf("write content: %w", err) + } + stdin.Close() + + if err := session2.Wait(); err != nil { + return fmt.Errorf("write failed: %w (stderr: %s)", err, stderrBuf.String()) + } + + return nil +} + +// Download copies a file from the remote host. +func (c *SSHClient) Download(ctx context.Context, remote string) ([]byte, error) { + if err := c.Connect(ctx); err != nil { + return nil, err + } + + cmd := fmt.Sprintf("cat %q", remote) + if c.become { + cmd = fmt.Sprintf("sudo cat %q", remote) + } + + stdout, stderr, exitCode, err := c.Run(ctx, cmd) + if err != nil { + return nil, err + } + if exitCode != 0 { + return nil, fmt.Errorf("cat failed: %s", stderr) + } + + return []byte(stdout), nil +} + +// FileExists checks if a file exists on the remote host. +func (c *SSHClient) FileExists(ctx context.Context, path string) (bool, error) { + cmd := fmt.Sprintf("test -e %q && echo yes || echo no", path) + stdout, _, exitCode, err := c.Run(ctx, cmd) + if err != nil { + return false, err + } + if exitCode != 0 { + // test command failed but didn't error - file doesn't exist + return false, nil + } + return strings.TrimSpace(stdout) == "yes", nil +} + +// Stat returns file info from the remote host. +func (c *SSHClient) Stat(ctx context.Context, path string) (map[string]any, error) { + // Use stat command to get file info + cmd := fmt.Sprintf(`stat -c '{"exists":true,"isdir":%s,"mode":"%a","size":%s,"uid":%u,"gid":%g}' %q 2>/dev/null || echo '{"exists":false}'`, + `$(test -d %q && echo true || echo false)`, + `%s`, + path) + + // Simpler approach - just get basic info + cmd = fmt.Sprintf(` +if [ -e %q ]; then + if [ -d %q ]; then + echo "exists=true isdir=true" + else + echo "exists=true isdir=false" + fi +else + echo "exists=false" +fi +`, path, path) + + stdout, _, _, err := c.Run(ctx, cmd) + if err != nil { + return nil, err + } + + result := make(map[string]any) + parts := strings.Fields(strings.TrimSpace(stdout)) + for _, part := range parts { + kv := strings.SplitN(part, "=", 2) + if len(kv) == 2 { + result[kv[0]] = kv[1] == "true" + } + } + + return result, nil +} + +// SetBecome enables privilege escalation. +func (c *SSHClient) SetBecome(become bool, user, password string) { + c.mu.Lock() + defer c.mu.Unlock() + c.become = become + if user != "" { + c.becomeUser = user + } + if password != "" { + c.becomePass = password + } +} diff --git a/pkg/ansible/types.go b/pkg/ansible/types.go new file mode 100644 index 00000000..79fe0543 --- /dev/null +++ b/pkg/ansible/types.go @@ -0,0 +1,258 @@ +package ansible + +import ( + "time" +) + +// Playbook represents an Ansible playbook. +type Playbook struct { + Plays []Play `yaml:",inline"` +} + +// Play represents a single play in a playbook. +type Play struct { + Name string `yaml:"name"` + Hosts string `yaml:"hosts"` + Connection string `yaml:"connection,omitempty"` + Become bool `yaml:"become,omitempty"` + BecomeUser string `yaml:"become_user,omitempty"` + GatherFacts *bool `yaml:"gather_facts,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + PreTasks []Task `yaml:"pre_tasks,omitempty"` + Tasks []Task `yaml:"tasks,omitempty"` + PostTasks []Task `yaml:"post_tasks,omitempty"` + Roles []RoleRef `yaml:"roles,omitempty"` + Handlers []Task `yaml:"handlers,omitempty"` + Tags []string `yaml:"tags,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + Serial any `yaml:"serial,omitempty"` // int or string + MaxFailPercent int `yaml:"max_fail_percentage,omitempty"` +} + +// RoleRef represents a role reference in a play. +type RoleRef struct { + Role string `yaml:"role,omitempty"` + Name string `yaml:"name,omitempty"` // Alternative to role + TasksFrom string `yaml:"tasks_from,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + When any `yaml:"when,omitempty"` + Tags []string `yaml:"tags,omitempty"` +} + +// UnmarshalYAML handles both string and struct role refs. +func (r *RoleRef) UnmarshalYAML(unmarshal func(any) error) error { + // Try string first + var s string + if err := unmarshal(&s); err == nil { + r.Role = s + return nil + } + + // Try struct + type rawRoleRef RoleRef + var raw rawRoleRef + if err := unmarshal(&raw); err != nil { + return err + } + *r = RoleRef(raw) + if r.Role == "" && r.Name != "" { + r.Role = r.Name + } + return nil +} + +// Task represents an Ansible task. +type Task struct { + Name string `yaml:"name,omitempty"` + Module string `yaml:"-"` // Derived from the module key + Args map[string]any `yaml:"-"` // Module arguments + Register string `yaml:"register,omitempty"` + When any `yaml:"when,omitempty"` // string or []string + Loop any `yaml:"loop,omitempty"` // string or []any + LoopControl *LoopControl `yaml:"loop_control,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + Environment map[string]string `yaml:"environment,omitempty"` + ChangedWhen any `yaml:"changed_when,omitempty"` + FailedWhen any `yaml:"failed_when,omitempty"` + IgnoreErrors bool `yaml:"ignore_errors,omitempty"` + NoLog bool `yaml:"no_log,omitempty"` + Become *bool `yaml:"become,omitempty"` + BecomeUser string `yaml:"become_user,omitempty"` + Delegate string `yaml:"delegate_to,omitempty"` + RunOnce bool `yaml:"run_once,omitempty"` + Tags []string `yaml:"tags,omitempty"` + Block []Task `yaml:"block,omitempty"` + Rescue []Task `yaml:"rescue,omitempty"` + Always []Task `yaml:"always,omitempty"` + Notify any `yaml:"notify,omitempty"` // string or []string + Retries int `yaml:"retries,omitempty"` + Delay int `yaml:"delay,omitempty"` + Until string `yaml:"until,omitempty"` + + // Include/import directives + IncludeTasks string `yaml:"include_tasks,omitempty"` + ImportTasks string `yaml:"import_tasks,omitempty"` + IncludeRole *struct { + Name string `yaml:"name"` + TasksFrom string `yaml:"tasks_from,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + } `yaml:"include_role,omitempty"` + ImportRole *struct { + Name string `yaml:"name"` + TasksFrom string `yaml:"tasks_from,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` + } `yaml:"import_role,omitempty"` + + // Raw YAML for module extraction + raw map[string]any +} + +// LoopControl controls loop behavior. +type LoopControl struct { + LoopVar string `yaml:"loop_var,omitempty"` + IndexVar string `yaml:"index_var,omitempty"` + Label string `yaml:"label,omitempty"` + Pause int `yaml:"pause,omitempty"` + Extended bool `yaml:"extended,omitempty"` +} + +// TaskResult holds the result of executing a task. +type TaskResult struct { + Changed bool `json:"changed"` + Failed bool `json:"failed"` + Skipped bool `json:"skipped"` + Msg string `json:"msg,omitempty"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + RC int `json:"rc,omitempty"` + Results []TaskResult `json:"results,omitempty"` // For loops + Data map[string]any `json:"data,omitempty"` // Module-specific data + Duration time.Duration `json:"duration,omitempty"` +} + +// Inventory represents Ansible inventory. +type Inventory struct { + All *InventoryGroup `yaml:"all"` +} + +// InventoryGroup represents a group in inventory. +type InventoryGroup struct { + Hosts map[string]*Host `yaml:"hosts,omitempty"` + Children map[string]*InventoryGroup `yaml:"children,omitempty"` + Vars map[string]any `yaml:"vars,omitempty"` +} + +// Host represents a host in inventory. +type Host struct { + AnsibleHost string `yaml:"ansible_host,omitempty"` + AnsiblePort int `yaml:"ansible_port,omitempty"` + AnsibleUser string `yaml:"ansible_user,omitempty"` + AnsiblePassword string `yaml:"ansible_password,omitempty"` + AnsibleSSHPrivateKeyFile string `yaml:"ansible_ssh_private_key_file,omitempty"` + AnsibleConnection string `yaml:"ansible_connection,omitempty"` + AnsibleBecomePassword string `yaml:"ansible_become_password,omitempty"` + + // Custom vars + Vars map[string]any `yaml:",inline"` +} + +// Facts holds gathered facts about a host. +type Facts struct { + Hostname string `json:"ansible_hostname"` + FQDN string `json:"ansible_fqdn"` + OS string `json:"ansible_os_family"` + Distribution string `json:"ansible_distribution"` + Version string `json:"ansible_distribution_version"` + Architecture string `json:"ansible_architecture"` + Kernel string `json:"ansible_kernel"` + Memory int64 `json:"ansible_memtotal_mb"` + CPUs int `json:"ansible_processor_vcpus"` + IPv4 string `json:"ansible_default_ipv4_address"` +} + +// Known Ansible modules +var KnownModules = []string{ + // Builtin + "ansible.builtin.shell", + "ansible.builtin.command", + "ansible.builtin.raw", + "ansible.builtin.script", + "ansible.builtin.copy", + "ansible.builtin.template", + "ansible.builtin.file", + "ansible.builtin.lineinfile", + "ansible.builtin.blockinfile", + "ansible.builtin.stat", + "ansible.builtin.slurp", + "ansible.builtin.fetch", + "ansible.builtin.get_url", + "ansible.builtin.uri", + "ansible.builtin.apt", + "ansible.builtin.apt_key", + "ansible.builtin.apt_repository", + "ansible.builtin.yum", + "ansible.builtin.dnf", + "ansible.builtin.package", + "ansible.builtin.pip", + "ansible.builtin.service", + "ansible.builtin.systemd", + "ansible.builtin.user", + "ansible.builtin.group", + "ansible.builtin.cron", + "ansible.builtin.git", + "ansible.builtin.unarchive", + "ansible.builtin.archive", + "ansible.builtin.debug", + "ansible.builtin.fail", + "ansible.builtin.assert", + "ansible.builtin.pause", + "ansible.builtin.wait_for", + "ansible.builtin.set_fact", + "ansible.builtin.include_vars", + "ansible.builtin.add_host", + "ansible.builtin.group_by", + "ansible.builtin.meta", + "ansible.builtin.setup", + + // Short forms (legacy) + "shell", + "command", + "raw", + "script", + "copy", + "template", + "file", + "lineinfile", + "blockinfile", + "stat", + "slurp", + "fetch", + "get_url", + "uri", + "apt", + "apt_key", + "apt_repository", + "yum", + "dnf", + "package", + "pip", + "service", + "systemd", + "user", + "group", + "cron", + "git", + "unarchive", + "archive", + "debug", + "fail", + "assert", + "pause", + "wait_for", + "set_fact", + "include_vars", + "add_host", + "group_by", + "meta", + "setup", +} diff --git a/pkg/deploy/coolify/client.go b/pkg/deploy/coolify/client.go new file mode 100644 index 00000000..8e4e3d4e --- /dev/null +++ b/pkg/deploy/coolify/client.go @@ -0,0 +1,216 @@ +package coolify + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sync" + + "github.com/host-uk/core/pkg/deploy/python" +) + +// Client wraps the Python CoolifyClient for Go usage. +type Client struct { + baseURL string + apiToken string + timeout int + verifySSL bool + + mu sync.Mutex +} + +// Config holds Coolify client configuration. +type Config struct { + BaseURL string + APIToken string + Timeout int + VerifySSL bool +} + +// DefaultConfig returns default configuration from environment. +func DefaultConfig() Config { + return Config{ + BaseURL: os.Getenv("COOLIFY_URL"), + APIToken: os.Getenv("COOLIFY_TOKEN"), + Timeout: 30, + VerifySSL: true, + } +} + +// NewClient creates a new Coolify client. +func NewClient(cfg Config) (*Client, error) { + if cfg.BaseURL == "" { + return nil, fmt.Errorf("COOLIFY_URL not set") + } + if cfg.APIToken == "" { + return nil, fmt.Errorf("COOLIFY_TOKEN not set") + } + + // Initialize Python runtime + if err := python.Init(); err != nil { + return nil, fmt.Errorf("failed to initialize Python: %w", err) + } + + return &Client{ + baseURL: cfg.BaseURL, + apiToken: cfg.APIToken, + timeout: cfg.Timeout, + verifySSL: cfg.VerifySSL, + }, nil +} + +// Call invokes a Coolify API operation by operationId. +func (c *Client) Call(ctx context.Context, operationID string, params map[string]any) (map[string]any, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if params == nil { + params = map[string]any{} + } + + // Generate and run Python script + script := python.CoolifyScript(c.baseURL, c.apiToken, operationID, params) + output, err := python.RunScript(ctx, script) + if err != nil { + return nil, fmt.Errorf("API call %s failed: %w", operationID, err) + } + + // Parse JSON result + var result map[string]any + if err := json.Unmarshal([]byte(output), &result); err != nil { + // Try parsing as array + var arrResult []any + if err2 := json.Unmarshal([]byte(output), &arrResult); err2 == nil { + return map[string]any{"result": arrResult}, nil + } + return nil, fmt.Errorf("failed to parse response: %w (output: %s)", err, output) + } + + return result, nil +} + +// ListServers returns all servers. +func (c *Client) ListServers(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "list-servers", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetServer returns a server by UUID. +func (c *Client) GetServer(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "get-server-by-uuid", map[string]any{"uuid": uuid}) +} + +// ValidateServer validates a server by UUID. +func (c *Client) ValidateServer(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "validate-server-by-uuid", map[string]any{"uuid": uuid}) +} + +// ListProjects returns all projects. +func (c *Client) ListProjects(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "list-projects", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetProject returns a project by UUID. +func (c *Client) GetProject(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "get-project-by-uuid", map[string]any{"uuid": uuid}) +} + +// CreateProject creates a new project. +func (c *Client) CreateProject(ctx context.Context, name, description string) (map[string]any, error) { + return c.Call(ctx, "create-project", map[string]any{ + "name": name, + "description": description, + }) +} + +// ListApplications returns all applications. +func (c *Client) ListApplications(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "list-applications", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetApplication returns an application by UUID. +func (c *Client) GetApplication(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "get-application-by-uuid", map[string]any{"uuid": uuid}) +} + +// DeployApplication triggers deployment of an application. +func (c *Client) DeployApplication(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "deploy-by-tag-or-uuid", map[string]any{"uuid": uuid}) +} + +// ListDatabases returns all databases. +func (c *Client) ListDatabases(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "list-databases", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetDatabase returns a database by UUID. +func (c *Client) GetDatabase(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "get-database-by-uuid", map[string]any{"uuid": uuid}) +} + +// ListServices returns all services. +func (c *Client) ListServices(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "list-services", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetService returns a service by UUID. +func (c *Client) GetService(ctx context.Context, uuid string) (map[string]any, error) { + return c.Call(ctx, "get-service-by-uuid", map[string]any{"uuid": uuid}) +} + +// ListEnvironments returns environments for a project. +func (c *Client) ListEnvironments(ctx context.Context, projectUUID string) ([]map[string]any, error) { + result, err := c.Call(ctx, "get-environments", map[string]any{"project_uuid": projectUUID}) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// GetTeam returns the current team. +func (c *Client) GetTeam(ctx context.Context) (map[string]any, error) { + return c.Call(ctx, "get-current-team", nil) +} + +// GetTeamMembers returns members of the current team. +func (c *Client) GetTeamMembers(ctx context.Context) ([]map[string]any, error) { + result, err := c.Call(ctx, "get-current-team-members", nil) + if err != nil { + return nil, err + } + return extractArray(result) +} + +// extractArray extracts an array from result["result"] or returns empty. +func extractArray(result map[string]any) ([]map[string]any, error) { + if arr, ok := result["result"].([]any); ok { + items := make([]map[string]any, 0, len(arr)) + for _, item := range arr { + if m, ok := item.(map[string]any); ok { + items = append(items, m) + } + } + return items, nil + } + return nil, nil +} diff --git a/pkg/deploy/python/python.go b/pkg/deploy/python/python.go new file mode 100644 index 00000000..3bac48b1 --- /dev/null +++ b/pkg/deploy/python/python.go @@ -0,0 +1,130 @@ +package python + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/kluctl/go-embed-python/python" +) + +var ( + once sync.Once + ep *python.EmbeddedPython + initErr error +) + +// Init initializes the embedded Python runtime. +func Init() error { + once.Do(func() { + ep, initErr = python.NewEmbeddedPython("core-deploy") + }) + return initErr +} + +// GetPython returns the embedded Python instance. +func GetPython() *python.EmbeddedPython { + return ep +} + +// RunScript runs a Python script with the given code and returns stdout. +func RunScript(ctx context.Context, code string, args ...string) (string, error) { + if err := Init(); err != nil { + return "", err + } + + // Write code to temp file + tmpFile, err := os.CreateTemp("", "core-*.py") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if _, err := tmpFile.WriteString(code); err != nil { + tmpFile.Close() + return "", fmt.Errorf("failed to write script: %w", err) + } + tmpFile.Close() + + // Build args: script path + any additional args + cmdArgs := append([]string{tmpFile.Name()}, args...) + + // Get the command + cmd, err := ep.PythonCmd(cmdArgs...) + if err != nil { + return "", fmt.Errorf("failed to create Python command: %w", err) + } + + // Run with context + output, err := cmd.Output() + if err != nil { + // Try to get stderr for better error message + if exitErr, ok := err.(*os.PathError); ok { + return "", fmt.Errorf("script failed: %v", exitErr) + } + return "", fmt.Errorf("script failed: %w", err) + } + + return string(output), nil +} + +// RunModule runs a Python module (python -m module_name). +func RunModule(ctx context.Context, module string, args ...string) (string, error) { + if err := Init(); err != nil { + return "", err + } + + cmdArgs := append([]string{"-m", module}, args...) + cmd, err := ep.PythonCmd(cmdArgs...) + if err != nil { + return "", fmt.Errorf("failed to create Python command: %w", err) + } + + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("module %s failed: %w", module, err) + } + + return string(output), nil +} + +// DevOpsPath returns the path to the DevOps repo. +func DevOpsPath() string { + if path := os.Getenv("DEVOPS_PATH"); path != "" { + return path + } + home, _ := os.UserHomeDir() + return filepath.Join(home, "Code", "DevOps") +} + +// CoolifyModulePath returns the path to the Coolify module_utils. +func CoolifyModulePath() string { + return filepath.Join(DevOpsPath(), "playbooks", "roles", "coolify", "module_utils") +} + +// CoolifyScript generates Python code to call the Coolify API. +func CoolifyScript(baseURL, apiToken, operation string, params map[string]any) string { + paramsJSON, _ := json.Marshal(params) + + return fmt.Sprintf(` +import sys +import json +sys.path.insert(0, %q) + +from swagger.coolify_api import CoolifyClient + +client = CoolifyClient( + base_url=%q, + api_token=%q, + timeout=30, + verify_ssl=True, +) + +params = json.loads(%q) +result = client._call(%q, params, check_response=False) +print(json.dumps(result)) +`, CoolifyModulePath(), baseURL, apiToken, string(paramsJSON), operation) +} diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 347f8405..d519556b 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -255,6 +255,10 @@ "changelog.short": "Generate changelog", "version.short": "Show or set version" }, + "deploy": { + "short": "Infrastructure deployment via Coolify", + "long": "Infrastructure deployment tools for managing Coolify servers, projects, applications, databases, and services." + }, "dev": { "short": "Multi-repo development workflow", "long": "Multi-repo development workflow tools for managing federated monorepos. Provides health checks, commit assistance, push/pull operations, and CI status across all repositories.",