feat(deploy): add pure-Go Ansible executor and Coolify API integration

Implement infrastructure deployment system with:

- pkg/ansible: Pure Go Ansible executor
  - Playbook/inventory parsing (types.go, parser.go)
  - Full execution engine with variable templating, loops, blocks,
    conditionals, handlers, and fact gathering (executor.go)
  - SSH client with key/password auth and privilege escalation (ssh.go)
  - 35+ module implementations: shell, command, copy, template, file,
    apt, service, systemd, user, group, git, docker_compose, etc. (modules.go)

- pkg/deploy/coolify: Coolify API client wrapping Python swagger client
  - List/get servers, projects, applications, databases, services
  - Generic Call() for any OpenAPI operation

- pkg/deploy/python: Embedded Python runtime for swagger client integration

- internal/cmd/deploy: CLI commands
  - core deploy servers/projects/apps/databases/services/team
  - core deploy call <operation> [params-json]

This enables Docker-free infrastructure deployment with Ansible-compatible
playbooks executed natively in Go.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-03 18:01:10 +00:00
parent ba7fa42522
commit a794f6b55f
14 changed files with 4440 additions and 1 deletions

6
go.mod
View file

@ -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

12
go.sum
View file

@ -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=

View file

@ -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 <playbook>",
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 <host>",
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
}

View file

@ -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)
}

View file

@ -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 <operation> [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]), &params); 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)
}

View file

@ -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"

968
pkg/ansible/executor.go Normal file
View file

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

1418
pkg/ansible/modules.go Normal file

File diff suppressed because it is too large Load diff

437
pkg/ansible/parser.go Normal file
View file

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

384
pkg/ansible/ssh.go Normal file
View file

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

258
pkg/ansible/types.go Normal file
View file

@ -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",
}

View file

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

130
pkg/deploy/python/python.go Normal file
View file

@ -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)
}

View file

@ -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.",