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:
parent
ba7fa42522
commit
a794f6b55f
14 changed files with 4440 additions and 1 deletions
6
go.mod
6
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
|
||||
|
|
|
|||
12
go.sum
12
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=
|
||||
|
|
|
|||
312
internal/cmd/deploy/cmd_ansible.go
Normal file
312
internal/cmd/deploy/cmd_ansible.go
Normal 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
|
||||
}
|
||||
15
internal/cmd/deploy/cmd_commands.go
Normal file
15
internal/cmd/deploy/cmd_commands.go
Normal 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)
|
||||
}
|
||||
280
internal/cmd/deploy/cmd_deploy.go
Normal file
280
internal/cmd/deploy/cmd_deploy.go
Normal 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]), ¶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)
|
||||
}
|
||||
|
|
@ -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
968
pkg/ansible/executor.go
Normal 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
1418
pkg/ansible/modules.go
Normal file
File diff suppressed because it is too large
Load diff
437
pkg/ansible/parser.go
Normal file
437
pkg/ansible/parser.go
Normal 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
384
pkg/ansible/ssh.go
Normal 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
258
pkg/ansible/types.go
Normal 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",
|
||||
}
|
||||
216
pkg/deploy/coolify/client.go
Normal file
216
pkg/deploy/coolify/client.go
Normal 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
130
pkg/deploy/python/python.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue