go-ansible/executor.go

3665 lines
83 KiB
Go
Raw Normal View History

package ansible
import (
"context"
2026-04-01 20:14:40 +00:00
"encoding/base64"
"errors"
"io"
"io/fs"
"maps"
"path"
"path/filepath"
"reflect"
"regexp"
"slices"
2026-04-01 19:54:13 +00:00
"strconv"
"strings"
"sync"
"text/template"
"time"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
2026-04-01 23:05:24 +00:00
"gopkg.in/yaml.v3"
)
var errEndPlay = errors.New("end play")
var errEndHost = errors.New("end host")
2026-04-01 21:25:32 +00:00
var errEndBatch = errors.New("end batch")
// sshExecutorClient is the client contract used by the executor.
type sshExecutorClient interface {
Run(ctx context.Context, cmd string) (stdout, stderr string, exitCode int, err error)
RunScript(ctx context.Context, script string) (stdout, stderr string, exitCode int, err error)
Upload(ctx context.Context, local io.Reader, remote string, mode fs.FileMode) error
Download(ctx context.Context, remote string) ([]byte, error)
FileExists(ctx context.Context, path string) (bool, error)
Stat(ctx context.Context, path string) (map[string]any, error)
BecomeState() (become bool, user, password string)
SetBecome(become bool, user, password string)
Close() error
}
2026-04-01 20:51:57 +00:00
// environmentSSHClient wraps another SSH client and prefixes commands with
// shell exports so play/task environment variables reach remote execution.
type environmentSSHClient struct {
sshExecutorClient
prefix string
}
func (c *environmentSSHClient) Run(ctx context.Context, cmd string) (string, string, int, error) {
return c.sshExecutorClient.Run(ctx, c.prefix+cmd)
}
func (c *environmentSSHClient) RunScript(ctx context.Context, script string) (string, string, int, error) {
return c.sshExecutorClient.RunScript(ctx, c.prefix+script)
}
// Executor runs Ansible playbooks.
//
// Example:
//
// exec := NewExecutor("/workspace/playbooks")
type Executor struct {
2026-04-02 02:25:28 +00:00
parser *Parser
inventory *Inventory
inventoryPath string
vars map[string]any
2026-04-02 13:53:06 +00:00
hostVars map[string]map[string]any
2026-04-02 02:25:28 +00:00
facts map[string]*Facts
results map[string]map[string]*TaskResult // host -> register_name -> result
handlers map[string][]Task
loadedRoleHandlers map[string]bool
notified map[string]bool
clients map[string]sshExecutorClient
batchFailedHosts map[string]bool
endedHosts map[string]bool
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.
//
// Example:
//
// exec := NewExecutor("/workspace/playbooks")
func NewExecutor(basePath string) *Executor {
return &Executor{
2026-04-02 02:25:28 +00:00
parser: NewParser(basePath),
vars: make(map[string]any),
2026-04-02 13:53:06 +00:00
hostVars: make(map[string]map[string]any),
2026-04-02 02:25:28 +00:00
facts: make(map[string]*Facts),
results: make(map[string]map[string]*TaskResult),
handlers: make(map[string][]Task),
loadedRoleHandlers: make(map[string]bool),
notified: make(map[string]bool),
clients: make(map[string]sshExecutorClient),
endedHosts: make(map[string]bool),
}
}
// SetInventory loads inventory from a file.
//
// Example:
//
// err := exec.SetInventory("/workspace/inventory.yml")
func (e *Executor) SetInventory(path string) error {
inv, err := e.parser.ParseInventory(path)
if err != nil {
return err
}
e.mu.Lock()
e.inventoryPath = path
e.inventory = inv
e.mu.Unlock()
return nil
}
// SetInventoryDirect sets inventory directly.
//
// Example:
//
// exec.SetInventoryDirect(&Inventory{All: &InventoryGroup{}})
func (e *Executor) SetInventoryDirect(inv *Inventory) {
e.mu.Lock()
e.inventoryPath = ""
e.inventory = inv
e.mu.Unlock()
}
// SetVar sets a variable.
//
// Example:
//
// exec.SetVar("env", "prod")
func (e *Executor) SetVar(key string, value any) {
e.mu.Lock()
defer e.mu.Unlock()
e.vars[key] = value
}
2026-04-02 13:53:06 +00:00
func (e *Executor) setHostVars(host string, values map[string]any) {
if host == "" || len(values) == 0 {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.hostVars == nil {
e.hostVars = make(map[string]map[string]any)
}
if e.hostVars[host] == nil {
e.hostVars[host] = make(map[string]any)
}
for key, value := range values {
e.hostVars[host][key] = value
}
}
func (e *Executor) hostScopedVars(host string) map[string]any {
e.mu.RLock()
defer e.mu.RUnlock()
if len(e.hostVars) == 0 {
return nil
}
values := e.hostVars[host]
if len(values) == 0 {
return nil
}
cloned := make(map[string]any, len(values))
for key, value := range values {
cloned[key] = value
}
return cloned
}
func inventoryHostnameShort(host string) string {
host = corexTrimSpace(host)
if host == "" {
return ""
}
short, _, ok := strings.Cut(host, ".")
if ok && short != "" {
return short
}
return host
}
func (e *Executor) hostMagicVars(host string) map[string]any {
values := map[string]any{
"inventory_hostname": host,
"inventory_hostname_short": inventoryHostnameShort(host),
}
if e != nil && e.inventory != nil {
if groupNames := hostGroupNames(e.inventory.All, host); len(groupNames) > 0 {
values["group_names"] = groupNames
}
if groups := inventoryGroupHosts(e.inventory); len(groups) > 0 {
values["groups"] = groups
}
if hostvars := inventoryHostVars(e.inventory); len(hostvars) > 0 {
values["hostvars"] = hostvars
}
}
if e != nil {
if facts, ok := e.facts[host]; ok {
values["ansible_facts"] = factsToMap(facts)
}
}
return values
}
func hostGroupNames(group *InventoryGroup, host string) []string {
if group == nil || host == "" {
return nil
}
names := make(map[string]bool)
collectHostGroupNames(group, host, "", names)
if len(names) == 0 {
return nil
}
result := make([]string, 0, len(names))
for name := range names {
result = append(result, name)
}
slices.Sort(result)
return result
}
func inventoryGroupHosts(inv *Inventory) map[string]any {
if inv == nil || inv.All == nil {
return nil
}
groups := make(map[string]any)
collectInventoryGroupHosts(inv.All, "all", groups)
return groups
}
func collectInventoryGroupHosts(group *InventoryGroup, name string, groups map[string]any) []string {
if group == nil {
return nil
}
hosts := getAllHosts(group)
if name != "" {
values := make([]any, len(hosts))
for i, host := range hosts {
values[i] = host
}
groups[name] = values
}
childNames := slices.Sorted(maps.Keys(group.Children))
for _, childName := range childNames {
collectInventoryGroupHosts(group.Children[childName], childName, groups)
}
return hosts
}
func inventoryHostVars(inv *Inventory) map[string]any {
if inv == nil || inv.All == nil {
return nil
}
hosts := getAllHosts(inv.All)
if len(hosts) == 0 {
return nil
}
values := make(map[string]any, len(hosts))
for _, host := range hosts {
hostVars := GetHostVars(inv, host)
if len(hostVars) == 0 {
values[host] = map[string]any{}
continue
}
values[host] = hostVars
}
return values
}
func collectHostGroupNames(group *InventoryGroup, host, name string, names map[string]bool) bool {
if group == nil {
return false
}
found := false
if _, ok := group.Hosts[host]; ok {
found = true
}
childNames := slices.Sorted(maps.Keys(group.Children))
for _, childName := range childNames {
if collectHostGroupNames(group.Children[childName], host, childName, names) {
found = true
}
}
if found && name != "" {
names[name] = true
}
return found
}
// Run executes a playbook.
//
// Example:
//
// err := exec.Run(context.Background(), "/workspace/playbooks/site.yml")
func (e *Executor) Run(ctx context.Context, playbookPath string) error {
plays, err := e.parser.ParsePlaybook(playbookPath)
if err != nil {
return coreerr.E("Executor.Run", "parse playbook", err)
}
for i := range plays {
if err := e.runPlay(ctx, &plays[i]); err != nil {
return coreerr.E("Executor.Run", sprintf("play %d (%s)", 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
}
e.endedHosts = make(map[string]bool)
// Merge play vars
2026-04-01 23:05:24 +00:00
if err := e.loadPlayVarsFiles(play); err != nil {
return err
}
for k, v := range play.Vars {
e.vars[k] = v
}
2026-04-02 02:25:28 +00:00
e.loadedRoleHandlers = make(map[string]bool)
2026-04-01 19:54:13 +00:00
for _, batch := range splitSerialHosts(hosts, play.Serial) {
if len(batch) == 0 {
continue
}
batch = e.filterActiveHosts(batch)
2026-04-01 19:54:13 +00:00
if len(batch) == 0 {
continue
}
2026-04-01 20:17:29 +00:00
e.batchFailedHosts = make(map[string]bool)
2026-04-01 19:54:13 +00:00
// Gather facts if needed
gatherFacts := play.GatherFacts == nil || *play.GatherFacts
if gatherFacts {
for _, host := range batch {
if err := e.gatherFacts(ctx, host, play); err != nil {
// Non-fatal
if e.Verbose > 0 {
coreerr.Warn("gather facts failed", "host", host, "err", err)
}
}
}
}
2026-04-01 19:54:13 +00:00
// Execute pre_tasks
for _, task := range play.PreTasks {
if err := e.runTaskOnHosts(ctx, batch, &task, play); err != nil {
if errors.Is(err, errEndPlay) {
return nil
}
2026-04-01 21:25:32 +00:00
if errors.Is(err, errEndBatch) {
goto nextBatch
}
2026-04-01 19:54:13 +00:00
return err
}
}
2026-04-01 19:54:13 +00:00
// Execute roles
for _, roleRef := range play.Roles {
if err := e.runRole(ctx, batch, &roleRef, play); err != nil {
if errors.Is(err, errEndPlay) {
return nil
}
2026-04-01 21:25:32 +00:00
if errors.Is(err, errEndBatch) {
goto nextBatch
}
2026-04-01 19:54:13 +00:00
return err
}
}
// Execute tasks
for _, task := range play.Tasks {
if err := e.runTaskOnHosts(ctx, batch, &task, play); err != nil {
if errors.Is(err, errEndPlay) {
return nil
}
2026-04-01 21:25:32 +00:00
if errors.Is(err, errEndBatch) {
goto nextBatch
}
2026-04-01 19:54:13 +00:00
return err
}
}
// Execute post_tasks
for _, task := range play.PostTasks {
if err := e.runTaskOnHosts(ctx, batch, &task, play); err != nil {
if errors.Is(err, errEndPlay) {
return nil
}
2026-04-01 21:25:32 +00:00
if errors.Is(err, errEndBatch) {
goto nextBatch
}
2026-04-01 19:54:13 +00:00
return err
}
}
// Run notified handlers for this batch.
if err := e.runNotifiedHandlers(ctx, batch, play); err != nil {
if errors.Is(err, errEndPlay) {
return nil
}
2026-04-01 21:25:32 +00:00
if errors.Is(err, errEndBatch) {
goto nextBatch
}
return err
}
2026-04-01 21:25:32 +00:00
nextBatch:
}
2026-04-01 19:54:13 +00:00
return nil
}
2026-04-01 23:05:24 +00:00
// loadPlayVarsFiles loads any play-level vars_files entries and merges them
// into the play's Vars map before execution begins.
func (e *Executor) loadPlayVarsFiles(play *Play) error {
if play == nil {
return nil
}
files := normalizeStringList(play.VarsFiles)
if len(files) == 0 {
return nil
}
merged := make(map[string]any)
for _, file := range files {
resolved := e.resolveLocalPath(file)
data, err := coreio.Local.Read(resolved)
if err != nil {
return coreerr.E("Executor.loadPlayVarsFiles", "read vars file", err)
}
var vars map[string]any
if err := yaml.Unmarshal([]byte(data), &vars); err != nil {
return coreerr.E("Executor.loadPlayVarsFiles", "parse vars file", err)
}
mergeVars(merged, vars, false)
}
if len(merged) == 0 {
return nil
}
if play.Vars == nil {
play.Vars = make(map[string]any)
}
mergeVars(merged, play.Vars, false)
play.Vars = merged
return nil
}
2026-04-01 19:54:13 +00:00
// splitSerialHosts splits a host list into serial batches.
func splitSerialHosts(hosts []string, serial any) [][]string {
2026-04-02 03:32:40 +00:00
if len(hosts) == 0 {
return nil
}
sizes := resolveSerialBatchSizes(serial, len(hosts))
if len(sizes) == 0 {
2026-04-01 19:54:13 +00:00
return [][]string{hosts}
}
2026-04-02 03:32:40 +00:00
batches := make([][]string, 0, len(sizes))
remaining := append([]string(nil), hosts...)
lastSize := sizes[len(sizes)-1]
for i := 0; len(remaining) > 0; i++ {
size := lastSize
if i < len(sizes) {
size = sizes[i]
}
if size <= 0 {
size = len(remaining)
}
2026-04-02 03:32:40 +00:00
if size > len(remaining) {
size = len(remaining)
}
batches = append(batches, append([]string(nil), remaining[:size]...))
remaining = remaining[size:]
}
2026-04-01 19:54:13 +00:00
return batches
}
2026-04-01 19:54:13 +00:00
// resolveSerialBatchSize converts a play serial value into a concrete batch size.
func resolveSerialBatchSize(serial any, total int) int {
2026-04-02 03:32:40 +00:00
sizes := resolveSerialBatchSizes(serial, total)
if len(sizes) == 0 {
return total
}
return sizes[0]
}
func resolveSerialBatchSizes(serial any, total int) []int {
2026-04-01 19:54:13 +00:00
if total <= 0 {
2026-04-02 03:32:40 +00:00
return nil
}
2026-04-01 19:54:13 +00:00
switch v := serial.(type) {
case nil:
2026-04-02 03:32:40 +00:00
return []int{total}
2026-04-01 19:54:13 +00:00
case int:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{v}
2026-04-01 19:54:13 +00:00
}
case int8:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case int16:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case int32:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case int64:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case uint:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case uint8:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case uint16:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case uint32:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case uint64:
if v > 0 {
2026-04-02 03:32:40 +00:00
return []int{int(v)}
2026-04-01 19:54:13 +00:00
}
case string:
s := corexTrimSpace(v)
if s == "" {
2026-04-02 03:32:40 +00:00
return []int{total}
2026-04-01 19:54:13 +00:00
}
if corexHasSuffix(s, "%") {
percent, err := strconv.Atoi(strings.TrimSuffix(s, "%"))
if err == nil && percent > 0 {
size := (total*percent + 99) / 100
if size < 1 {
size = 1
}
if size > total {
size = total
}
2026-04-02 03:32:40 +00:00
return []int{size}
2026-04-01 19:54:13 +00:00
}
}
if n, err := strconv.Atoi(s); err == nil && n > 0 {
2026-04-02 03:32:40 +00:00
return []int{n}
}
case []int:
sizes := make([]int, 0, len(v))
for _, item := range v {
if size := resolveSerialBatchSize(item, total); size > 0 {
sizes = append(sizes, size)
}
}
if len(sizes) > 0 {
return sizes
}
case []string:
sizes := make([]int, 0, len(v))
for _, item := range v {
if size := resolveSerialBatchSize(item, total); size > 0 {
sizes = append(sizes, size)
}
}
if len(sizes) > 0 {
return sizes
}
case []any:
sizes := make([]int, 0, len(v))
for _, item := range v {
if size := resolveSerialBatchSize(item, total); size > 0 {
sizes = append(sizes, size)
}
}
if len(sizes) > 0 {
return sizes
2026-04-01 19:54:13 +00:00
}
}
2026-04-02 03:32:40 +00:00
return []int{total}
}
// runRole executes a role on hosts.
func (e *Executor) runRole(ctx context.Context, hosts []string, roleRef *RoleRef, play *Play) error {
oldVars := make(map[string]any, len(e.vars))
for k, v := range e.vars {
oldVars[k] = v
}
tasks, defaults, roleVars, err := e.parser.loadRoleData(roleRef.Role, roleRef.TasksFrom, roleRef.DefaultsFrom, roleRef.VarsFrom)
if err != nil {
e.vars = oldVars
return coreerr.E("executor.runRole", sprintf("parse role %s", roleRef.Role), err)
}
if err := e.attachRoleHandlers(roleRef.Role, roleRef.HandlersFrom, play); err != nil {
2026-04-02 02:25:28 +00:00
e.vars = oldVars
return coreerr.E("executor.runRole", sprintf("load handlers for role %s", roleRef.Role), err)
}
roleScope := make(map[string]any, len(oldVars)+len(defaults)+len(roleVars)+len(roleRef.Vars))
for k, v := range oldVars {
roleScope[k] = v
}
for k, v := range defaults {
if _, exists := roleScope[k]; !exists {
roleScope[k] = v
}
}
for k, v := range roleVars {
roleScope[k] = v
}
for k, v := range roleRef.Vars {
roleScope[k] = v
}
e.vars = roleScope
2026-04-01 20:32:50 +00:00
eligibleHosts := make([]string, 0, len(hosts))
for _, host := range hosts {
if roleRef.When == nil || e.evaluateWhen(roleRef.When, host, nil) {
eligibleHosts = append(eligibleHosts, host)
}
}
if len(eligibleHosts) == 0 {
e.vars = oldVars
return nil
}
// Execute tasks
for _, task := range tasks {
2026-04-01 20:30:15 +00:00
effectiveTask := task
e.applyRoleTaskDefaults(&effectiveTask, roleRef.Apply)
2026-04-01 20:30:15 +00:00
if len(roleRef.Tags) > 0 {
effectiveTask.Tags = mergeStringSlices(roleRef.Tags, effectiveTask.Tags)
2026-04-01 20:30:15 +00:00
}
2026-04-01 20:32:50 +00:00
if err := e.runTaskOnHosts(ctx, eligibleHosts, &effectiveTask, play); err != nil {
// Restore vars
e.vars = oldVars
return err
}
}
// Restore vars
if roleRef == nil || !roleRef.Public {
e.vars = oldVars
}
return nil
}
func (e *Executor) attachRoleHandlers(roleName, handlersFrom string, play *Play) error {
2026-04-02 02:25:28 +00:00
if play == nil || roleName == "" {
return nil
}
if e.loadedRoleHandlers == nil {
e.loadedRoleHandlers = make(map[string]bool)
}
if handlersFrom == "" {
handlersFrom = "main.yml"
}
key := roleName + "|handlers/" + handlersFrom
2026-04-02 02:25:28 +00:00
if e.loadedRoleHandlers[key] {
return nil
}
handlers, err := e.parser.loadRoleHandlers(roleName, handlersFrom)
2026-04-02 02:25:28 +00:00
if err != nil {
return err
}
if len(handlers) > 0 {
play.Handlers = append(play.Handlers, handlers...)
}
e.loadedRoleHandlers[key] = true
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
2026-04-01 21:10:02 +00:00
if !e.matchesTags(effectiveTaskTags(task, play)) {
return nil
}
// run_once executes the task against a single host, then shares any
// registered result with the rest of the host set.
if task.RunOnce && len(hosts) > 0 {
single := *task
single.RunOnce = false
if err := e.runTaskOnHosts(ctx, hosts[:1], &single, play); err != nil {
return err
}
e.copyRegisteredResultToHosts(hosts, hosts[0], task.Register)
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 e.isHostEnded(host) {
continue
}
2026-04-01 19:51:23 +00:00
if err := e.runTaskOnHost(ctx, host, hosts, task, play); err != nil {
if errors.Is(err, errEndHost) {
continue
}
if !task.IgnoreErrors {
return err
}
}
2026-04-01 20:17:29 +00:00
if err := e.checkMaxFailPercentage(play, hosts); err != nil {
return err
}
}
return nil
}
2026-04-01 21:10:02 +00:00
func effectiveTaskTags(task *Task, play *Play) []string {
var tags []string
if play != nil && len(play.Tags) > 0 {
tags = append(tags, play.Tags...)
}
if task != nil && len(task.Tags) > 0 {
tags = append(tags, task.Tags...)
}
if task != nil {
switch {
case task.IncludeRole != nil && len(task.IncludeRole.Tags) > 0:
tags = append(tags, task.IncludeRole.Tags...)
case task.ImportRole != nil && len(task.ImportRole.Tags) > 0:
tags = append(tags, task.ImportRole.Tags...)
}
}
2026-04-01 21:10:02 +00:00
return tags
}
func mergeStringSlices(parts ...[]string) []string {
total := 0
for _, part := range parts {
total += len(part)
}
if total == 0 {
return nil
}
merged := make([]string, 0, total)
for _, part := range parts {
merged = append(merged, part...)
}
return merged
}
// copyRegisteredResultToHosts shares a registered task result from one host to
// the rest of the current host set.
func (e *Executor) copyRegisteredResultToHosts(hosts []string, sourceHost, register string) {
if register == "" {
return
}
sourceResults := e.results[sourceHost]
if sourceResults == nil {
return
}
result, ok := sourceResults[register]
if !ok || result == nil {
return
}
for _, host := range hosts {
if host == sourceHost {
continue
}
if e.results[host] == nil {
e.results[host] = make(map[string]*TaskResult)
}
clone := *result
if result.Results != nil {
clone.Results = append([]TaskResult(nil), result.Results...)
}
if result.Data != nil {
clone.Data = make(map[string]any, len(result.Data))
for k, v := range result.Data {
clone.Data[k] = v
}
}
e.results[host][register] = &clone
}
}
// runTaskOnHost runs a task on a single host.
2026-04-01 19:51:23 +00:00
func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []string, task *Task, play *Play) error {
if e.isHostEnded(host) {
return nil
}
start := time.Now()
e.mu.Lock()
oldInventoryHostname, hadInventoryHostname := e.vars["inventory_hostname"]
e.vars["inventory_hostname"] = host
e.mu.Unlock()
defer func() {
e.mu.Lock()
if hadInventoryHostname {
e.vars["inventory_hostname"] = oldInventoryHostname
} else {
delete(e.vars, "inventory_hostname")
}
e.mu.Unlock()
}()
if e.OnTaskStart != nil {
e.OnTaskStart(host, task)
}
// Initialise 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
}
}
// Honour check mode for tasks that would mutate state.
if e.CheckMode && !isCheckModeSafeTask(task) {
result := &TaskResult{Skipped: true, Msg: "Skipped in check mode"}
if task.Register != "" {
e.results[host][task.Register] = result
}
if e.OnTaskEnd != nil {
e.OnTaskEnd(host, task, result)
}
return nil
}
// Get SSH client
executionHost := host
if task.Delegate != "" {
executionHost = e.templateString(task.Delegate, host, task)
if executionHost == "" {
executionHost = host
}
}
client, err := e.getClient(executionHost, play)
if err != nil {
return coreerr.E("Executor.runTaskOnHost", sprintf("get client for %s", executionHost), err)
}
// Handle loops, including legacy with_file, with_fileglob, with_sequence,
// with_together, and with_subelements syntax.
if task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil || task.WithTogether != nil || task.WithSubelements != nil {
2026-04-01 23:34:33 +00:00
return e.runLoop(ctx, host, client, task, play, start)
}
// Execute the task, honouring retries/until when configured.
result, err := e.runTaskWithRetries(ctx, host, task, play, func() (*TaskResult, error) {
return 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
}
2026-04-01 21:22:32 +00:00
displayResult := result
if task.NoLog {
displayResult = redactTaskResult(result)
}
// Handle notify
if result.Changed && task.Notify != nil {
e.handleNotify(task.Notify)
}
if e.OnTaskEnd != nil {
2026-04-01 21:22:32 +00:00
e.OnTaskEnd(host, task, displayResult)
}
2026-04-01 19:51:23 +00:00
if NormalizeModule(task.Module) == "ansible.builtin.meta" {
if err := e.handleMetaAction(ctx, host, hosts, play, result); err != nil {
2026-04-01 19:51:23 +00:00
return err
}
}
if result.Failed && !task.IgnoreErrors {
2026-04-01 20:17:29 +00:00
e.markBatchHostFailed(host)
2026-04-01 21:22:32 +00:00
return taskFailureError(task, result)
}
2026-04-01 20:17:29 +00:00
if result.Failed {
e.markBatchHostFailed(host)
}
return nil
}
func (e *Executor) markBatchHostFailed(host string) {
if host == "" {
return
}
if e.batchFailedHosts == nil {
e.batchFailedHosts = make(map[string]bool)
}
e.batchFailedHosts[host] = true
}
func (e *Executor) checkMaxFailPercentage(play *Play, hosts []string) error {
if play == nil || play.MaxFailPercent <= 0 || len(hosts) == 0 {
return nil
}
threshold := play.MaxFailPercent
failed := 0
for _, host := range hosts {
if e.batchFailedHosts != nil && e.batchFailedHosts[host] {
failed++
}
}
if failed == 0 {
return nil
}
percentage := (failed * 100) / len(hosts)
if percentage > threshold {
return coreerr.E("Executor.runTaskOnHosts", sprintf("max fail percentage exceeded: %d%% failed (threshold %d%%)", percentage, threshold), nil)
}
return nil
}
2026-04-01 21:22:32 +00:00
func redactTaskResult(result *TaskResult) *TaskResult {
if result == nil {
return nil
}
redacted := *result
redacted.Msg = "censored due to no_log"
redacted.Stdout = ""
redacted.Stderr = ""
redacted.Data = nil
if len(result.Results) > 0 {
redacted.Results = make([]TaskResult, len(result.Results))
for i := range result.Results {
redacted.Results[i] = *redactTaskResult(&result.Results[i])
}
}
return &redacted
}
func taskFailureError(task *Task, result *TaskResult) error {
if task != nil && task.NoLog {
return coreerr.E("Executor.runTaskOnHost", "task failed", nil)
}
msg := "task failed"
if result != nil && result.Msg != "" {
msg += ": " + result.Msg
}
return coreerr.E("Executor.runTaskOnHost", msg, nil)
}
// runTaskWithRetries executes a task once or multiple times when retries,
// delay, or until are configured.
func (e *Executor) runTaskWithRetries(ctx context.Context, host string, task *Task, play *Play, execute func() (*TaskResult, error)) (*TaskResult, error) {
attempts := 1
if task != nil {
if task.Until != "" {
if task.Retries > 0 {
attempts = task.Retries + 1
} else {
attempts = 4
}
} else if task.Retries > 0 {
attempts = task.Retries + 1
}
}
var result *TaskResult
var err error
for attempt := 1; attempt <= attempts; attempt++ {
result, err = execute()
if err != nil {
result = &TaskResult{Failed: true, Msg: err.Error()}
}
if result == nil {
result = &TaskResult{}
}
e.applyTaskResultConditions(host, task, result)
if !shouldRetryTask(task, host, e, result) || attempt == attempts {
break
}
if task != nil && task.Delay > 0 {
timer := time.NewTimer(time.Duration(task.Delay) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return result, ctx.Err()
case <-timer.C:
}
}
}
return result, nil
}
func shouldRetryTask(task *Task, host string, e *Executor, result *TaskResult) bool {
if task == nil || result == nil {
return false
}
if task.Until != "" {
return !e.evaluateWhenWithLocals(task.Until, host, task, map[string]any{
"result": result,
"stdout": result.Stdout,
"stderr": result.Stderr,
"rc": result.RC,
"changed": result.Changed,
"failed": result.Failed,
"skipped": result.Skipped,
"msg": result.Msg,
"duration": result.Duration,
})
}
if task.Retries > 0 {
return result.Failed
}
return false
}
// runLoop handles task loops.
2026-04-01 23:34:33 +00:00
func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorClient, task *Task, play *Play, start time.Time) error {
2026-04-01 21:07:04 +00:00
var (
items []any
err error
)
if task.WithFile != nil {
items, err = e.resolveWithFileLoop(task.WithFile, host, task)
} else if task.WithFileGlob != nil {
items, err = e.resolveWithFileGlobLoop(task.WithFileGlob, host, task)
2026-04-01 22:44:12 +00:00
} else if task.WithSequence != nil {
items, err = e.resolveWithSequenceLoop(task.WithSequence, host, task)
} else if task.WithTogether != nil {
items, err = e.resolveWithTogetherLoop(task.WithTogether, host, task)
} else if task.WithSubelements != nil {
items, err = e.resolveWithSubelementsLoop(task.WithSubelements, host, task)
2026-04-01 21:07:04 +00:00
} else {
items = e.resolveLoopWithTask(task.Loop, host, task)
2026-04-01 21:07:04 +00:00
}
if err != nil {
return err
}
loopVar := "item"
if task.LoopControl != nil && task.LoopControl.LoopVar != "" {
loopVar = task.LoopControl.LoopVar
}
// Save loop state to restore after loop
savedVars := make(map[string]any)
if v, ok := e.vars[loopVar]; ok {
savedVars[loopVar] = v
}
indexVar := ""
if task.LoopControl != nil && task.LoopControl.IndexVar != "" {
indexVar = task.LoopControl.IndexVar
if v, ok := e.vars[indexVar]; ok {
savedVars[indexVar] = v
}
}
var savedLoopMeta any
if task.LoopControl != nil && (task.LoopControl.Extended || task.LoopControl.Label != "") {
if v, ok := e.vars["ansible_loop"]; ok {
savedLoopMeta = v
}
}
var results []TaskResult
for i, item := range items {
// Set loop variables
e.vars[loopVar] = item
if indexVar != "" {
e.vars[indexVar] = i
}
if task.LoopControl != nil && (task.LoopControl.Extended || task.LoopControl.Label != "") {
loopMeta := map[string]any{}
if task.LoopControl.Extended {
var prevItem any
if i > 0 {
prevItem = items[i-1]
}
var nextItem any
if i+1 < len(items) {
nextItem = items[i+1]
}
loopMeta["index"] = i + 1
loopMeta["index0"] = i
loopMeta["first"] = i == 0
loopMeta["last"] = i == len(items)-1
loopMeta["length"] = len(items)
loopMeta["revindex"] = len(items) - i
loopMeta["revindex0"] = len(items) - i - 1
loopMeta["allitems"] = append([]any(nil), items...)
if prevItem != nil {
loopMeta["previtem"] = prevItem
}
if nextItem != nil {
loopMeta["nextitem"] = nextItem
}
}
if task.LoopControl.Label != "" {
loopMeta["label"] = e.templateString(task.LoopControl.Label, host, task)
}
e.vars["ansible_loop"] = loopMeta
}
result, err := e.runTaskWithRetries(ctx, host, task, play, func() (*TaskResult, error) {
return e.executeModule(ctx, host, client, task, play)
})
if err != nil {
result = &TaskResult{Failed: true, Msg: err.Error()}
}
results = append(results, *result)
if task.LoopControl != nil && task.LoopControl.Pause > 0 && i < len(items)-1 {
timer := time.NewTimer(time.Duration(task.LoopControl.Pause) * time.Second)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
if result.Failed && !task.IgnoreErrors {
break
}
}
// Restore loop variables
if v, ok := savedVars[loopVar]; ok {
e.vars[loopVar] = v
} else {
delete(e.vars, loopVar)
}
if indexVar != "" {
if v, ok := savedVars[indexVar]; ok {
e.vars[indexVar] = v
} else {
delete(e.vars, indexVar)
}
}
if task.LoopControl != nil && (task.LoopControl.Extended || task.LoopControl.Label != "") {
if savedLoopMeta != nil {
e.vars["ansible_loop"] = savedLoopMeta
} else {
delete(e.vars, "ansible_loop")
}
}
// 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
}
}
2026-04-01 23:34:33 +00:00
combined.Duration = time.Since(start)
e.results[host][task.Register] = combined
}
2026-04-01 23:34:33 +00:00
result := &TaskResult{
Results: results,
Changed: false,
}
for _, r := range results {
if r.Changed {
result.Changed = true
}
if r.Failed {
result.Failed = true
}
}
result.Duration = time.Since(start)
displayResult := result
if task.NoLog {
displayResult = redactTaskResult(result)
}
if result.Changed && task.Notify != nil {
e.handleNotify(task.Notify)
}
if e.OnTaskEnd != nil {
e.OnTaskEnd(host, task, displayResult)
}
if result.Failed {
e.markBatchHostFailed(host)
if !task.IgnoreErrors {
return taskFailureError(task, result)
}
}
return nil
}
2026-04-01 21:07:04 +00:00
// resolveWithFileLoop resolves legacy with_file loop items into file contents.
func (e *Executor) resolveWithFileLoop(loop any, host string, task *Task) ([]any, error) {
var paths []string
switch v := loop.(type) {
case []any:
paths = make([]string, 0, len(v))
for _, item := range v {
s := sprintf("%v", item)
if s = e.templateString(s, host, task); s != "" {
paths = append(paths, s)
}
}
case []string:
paths = make([]string, 0, len(v))
for _, item := range v {
if s := e.templateString(item, host, task); s != "" {
paths = append(paths, s)
}
}
case string:
if s := e.templateString(v, host, task); s != "" {
paths = []string{s}
}
default:
return nil, nil
}
items := make([]any, 0, len(paths))
for _, filePath := range paths {
content, err := e.readLoopFile(filePath)
if err != nil {
return nil, err
}
items = append(items, content)
}
return items, nil
}
// resolveWithFileGlobLoop resolves legacy with_fileglob loop items into matching file paths.
func (e *Executor) resolveWithFileGlobLoop(loop any, host string, task *Task) ([]any, error) {
var patterns []string
switch v := loop.(type) {
case []any:
patterns = make([]string, 0, len(v))
for _, item := range v {
s := sprintf("%v", item)
if s = e.templateString(s, host, task); s != "" {
patterns = append(patterns, s)
}
}
case []string:
patterns = make([]string, 0, len(v))
for _, item := range v {
if s := e.templateString(item, host, task); s != "" {
patterns = append(patterns, s)
}
}
case string:
if s := e.templateString(v, host, task); s != "" {
patterns = []string{s}
}
default:
return nil, nil
}
items := make([]any, 0)
for _, pattern := range patterns {
matches, err := e.resolveFileGlob(pattern)
if err != nil {
return nil, err
}
for _, match := range matches {
items = append(items, match)
}
}
return items, nil
}
2026-04-01 22:44:12 +00:00
type sequenceSpec struct {
start int
end int
count int
step int
format string
hasEnd bool
}
func (e *Executor) resolveWithSequenceLoop(loop any, host string, task *Task) ([]any, error) {
spec, err := parseSequenceSpec(loop)
if err != nil {
return nil, err
}
values, err := buildSequenceValues(spec)
if err != nil {
return nil, err
}
items := make([]any, len(values))
for i, value := range values {
items[i] = value
}
return items, nil
}
func (e *Executor) resolveWithTogetherLoop(loop any, host string, task *Task) ([]any, error) {
items := expandTogetherLoop(loop)
if len(items) == 0 {
return nil, nil
}
return items, nil
}
func (e *Executor) resolveWithSubelementsLoop(loop any, host string, task *Task) ([]any, error) {
source, subelement, skipMissing, ok := parseSubelementsSpec(loop)
if !ok {
return nil, nil
}
if subelement == "" {
return nil, coreerr.E("Executor.resolveWithSubelementsLoop", "with_subelements requires a subelement name", nil)
}
parents := e.resolveSubelementsParents(source, host, task)
items := make([]any, 0)
for _, parent := range parents {
subelementValues, found := subelementItems(parent, subelement)
if !found {
if skipMissing {
continue
}
return nil, coreerr.E("Executor.resolveWithSubelementsLoop", sprintf("with_subelements missing subelement %q", subelement), nil)
}
for _, subitem := range subelementValues {
items = append(items, []any{parent, subitem})
}
}
return items, nil
}
func parseSubelementsSpec(loop any) (any, string, bool, bool) {
switch v := loop.(type) {
case []any:
if len(v) < 2 {
return nil, "", false, false
}
return v[0], sprintf("%v", v[1]), parseSubelementsSkipMissing(v[2:]), true
case []string:
if len(v) < 2 {
return nil, "", false, false
}
return v[0], v[1], parseSubelementsSkipMissingStrings(v[2:]), true
case string:
parts := strings.Fields(v)
if len(parts) < 2 {
return nil, "", false, false
}
return parts[0], parts[1], parseSubelementsSkipMissingStrings(parts[2:]), true
default:
return nil, "", false, false
}
}
func parseSubelementsSkipMissing(values []any) bool {
for _, value := range values {
if parseSkipMissingValue(value) {
return true
}
}
return false
}
func parseSubelementsSkipMissingStrings(values []string) bool {
for _, value := range values {
if parseSkipMissingValue(value) {
return true
}
}
return false
}
func parseSkipMissingValue(value any) bool {
switch v := value.(type) {
case bool:
return v
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return false
}
if trimmed == "skip_missing" {
return true
}
if strings.HasPrefix(trimmed, "skip_missing=") {
return getBoolArg(map[string]any{"skip_missing": strings.TrimPrefix(trimmed, "skip_missing=")}, "skip_missing", false)
}
return getBoolArg(map[string]any{"skip_missing": trimmed}, "skip_missing", false)
case map[string]any:
return getBoolArg(v, "skip_missing", false)
case map[any]any:
converted := make(map[string]any, len(v))
for key, val := range v {
if s, ok := key.(string); ok {
converted[s] = val
}
}
return getBoolArg(converted, "skip_missing", false)
default:
return false
}
}
func (e *Executor) resolveSubelementsParents(value any, host string, task *Task) []any {
switch v := value.(type) {
case string:
if items := e.resolveLoopWithTask(v, host, task); len(items) > 0 {
return items
}
if items, ok := anySliceFromValue(e.templateString(v, host, task)); ok {
return items
}
if task != nil {
if items, ok := anySliceFromValue(task.Vars[v]); ok {
return items
}
}
default:
if items, ok := anySliceFromValue(v); ok {
return items
}
}
return nil
}
func subelementItems(parent any, path string) ([]any, bool) {
value, ok := lookupNestedValue(parent, path)
if !ok || value == nil {
return nil, false
}
if items, ok := anySliceFromValue(value); ok {
return items, true
}
return []any{value}, true
}
2026-04-01 22:44:12 +00:00
func parseSequenceSpec(loop any) (*sequenceSpec, error) {
spec := &sequenceSpec{
step: 1,
format: "%d",
}
switch v := loop.(type) {
case string:
if err := parseSequenceSpecString(spec, v); err != nil {
return nil, err
}
case map[string]any:
if err := parseSequenceSpecMap(spec, v); err != nil {
return nil, err
}
case map[any]any:
converted := make(map[string]any, len(v))
for key, value := range v {
if s, ok := key.(string); ok {
converted[s] = value
}
}
if err := parseSequenceSpecMap(spec, converted); err != nil {
return nil, err
}
default:
return nil, coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence requires a string or mapping", nil)
}
if spec.count > 0 && !spec.hasEnd {
spec.end = spec.start + (spec.count-1)*spec.step
spec.hasEnd = true
}
if !spec.hasEnd {
return nil, coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence requires end or count", nil)
}
if spec.step == 0 {
return nil, coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence stride must not be zero", nil)
}
if spec.end < spec.start && spec.step > 0 {
spec.step = -spec.step
}
if spec.end > spec.start && spec.step < 0 {
spec.step = -spec.step
}
return spec, nil
}
func parseSequenceSpecString(spec *sequenceSpec, raw string) error {
fields := strings.Fields(strings.ReplaceAll(raw, ",", " "))
for _, field := range fields {
parts := strings.SplitN(field, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if err := applySequenceSpecValue(spec, key, value); err != nil {
return err
}
}
if spec.start == 0 && !strings.Contains(raw, "start=") {
spec.start = 0
}
return nil
}
func parseSequenceSpecMap(spec *sequenceSpec, values map[string]any) error {
for key, value := range values {
if err := applySequenceSpecValue(spec, key, value); err != nil {
return err
}
}
return nil
}
func applySequenceSpecValue(spec *sequenceSpec, key string, value any) error {
switch lower(strings.TrimSpace(key)) {
case "start":
n, ok := sequenceSpecInt(value)
if !ok {
return coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence start must be numeric", nil)
}
spec.start = n
case "end":
n, ok := sequenceSpecInt(value)
if !ok {
return coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence end must be numeric", nil)
}
spec.end = n
spec.hasEnd = true
case "count":
n, ok := sequenceSpecInt(value)
if !ok {
return coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence count must be numeric", nil)
}
spec.count = n
case "stride":
n, ok := sequenceSpecInt(value)
if !ok {
return coreerr.E("Executor.resolveWithSequenceLoop", "with_sequence stride must be numeric", nil)
}
spec.step = n
case "format":
spec.format = sprintf("%v", value)
default:
// Ignore unrecognised keys so the parser stays tolerant of extra
// Ansible sequence options we do not currently model.
}
return nil
}
func sequenceSpecInt(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int8:
return int(v), true
case int16:
return int(v), true
case int32:
return int(v), true
case int64:
return int(v), true
case uint:
return int(v), true
case uint8:
return int(v), true
case uint16:
return int(v), true
case uint32:
return int(v), true
case uint64:
return int(v), true
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return n, true
}
}
return 0, false
}
func buildSequenceValues(spec *sequenceSpec) ([]string, error) {
if spec.count > 0 && !spec.hasEnd {
spec.end = spec.start + (spec.count-1)*spec.step
spec.hasEnd = true
}
values := make([]string, 0)
if spec.step > 0 {
for value := spec.start; value <= spec.end; value += spec.step {
values = append(values, formatSequenceValue(spec.format, value))
}
return values, nil
}
for value := spec.start; value >= spec.end; value += spec.step {
values = append(values, formatSequenceValue(spec.format, value))
}
return values, nil
}
func formatSequenceValue(format string, value int) string {
if format == "" {
return sprintf("%d", value)
}
formatted := sprintf(format, value)
if formatted == "" {
return sprintf("%d", value)
}
return formatted
}
func (e *Executor) resolveFileGlob(pattern string) ([]any, error) {
candidates := []string{pattern}
if e.parser != nil && e.parser.basePath != "" && !path.IsAbs(pattern) {
candidates = append([]string{joinPath(e.parser.basePath, pattern)}, candidates...)
}
var matches []string
for _, candidate := range candidates {
globMatches, err := filepath.Glob(candidate)
if err != nil {
return nil, coreerr.E("Executor.resolveFileGlob", "glob pattern "+pattern, err)
}
if len(globMatches) > 0 {
matches = append(matches, globMatches...)
break
}
}
slices.Sort(matches)
result := make([]any, len(matches))
for i, match := range matches {
result[i] = match
}
return result, nil
}
2026-04-01 21:07:04 +00:00
func (e *Executor) readLoopFile(filePath string) (string, error) {
candidates := []string{filePath}
if e.parser != nil && e.parser.basePath != "" {
candidates = append([]string{joinPath(e.parser.basePath, filePath)}, candidates...)
}
for _, candidate := range candidates {
if data, err := coreio.Local.Read(candidate); err == nil {
return data, nil
}
}
return "", coreerr.E("Executor.readLoopFile", "read file "+filePath, nil)
}
// isCheckModeSafeTask reports whether a task can run without changing state
// during check mode.
func isCheckModeSafeTask(task *Task) bool {
if task == nil {
return true
}
if len(task.Block) > 0 || len(task.Rescue) > 0 || len(task.Always) > 0 {
return true
}
if task.IncludeTasks != "" || task.ImportTasks != "" {
return true
}
if task.IncludeRole != nil || task.ImportRole != nil {
return true
}
switch NormalizeModule(task.Module) {
case "ansible.builtin.debug",
"ansible.builtin.fail",
"ansible.builtin.assert",
"ansible.builtin.ping",
"ansible.builtin.pause",
"ansible.builtin.wait_for",
"ansible.builtin.stat",
"ansible.builtin.slurp",
"ansible.builtin.include_vars",
"ansible.builtin.meta",
"ansible.builtin.set_fact",
"ansible.builtin.add_host",
"ansible.builtin.group_by",
"ansible.builtin.setup":
return true
default:
return false
}
}
// 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 {
if err := e.runTaskOnHosts(ctx, hosts, &t, play); err != nil {
if blockErr == nil {
blockErr = err
}
}
}
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
}
2026-04-01 21:57:05 +00:00
if path == "" || len(hosts) == 0 {
return nil
}
// Static include/import tasks still honour their own when-clause before the
// child task file is expanded for each host.
if task.When != nil {
filtered := make([]string, 0, len(hosts))
for _, host := range hosts {
if e.evaluateWhen(task.When, host, task) {
filtered = append(filtered, host)
}
}
hosts = filtered
if len(hosts) == 0 {
return nil
}
}
2026-04-01 21:57:05 +00:00
// Resolve the include path per host so host-specific vars can select a
// different task file for each target.
hostsByPath := make(map[string][]string)
pathOrder := make([]string, 0, len(hosts))
for _, host := range hosts {
resolvedPath := e.templateString(path, host, task)
if resolvedPath == "" {
continue
}
if _, ok := hostsByPath[resolvedPath]; !ok {
pathOrder = append(pathOrder, resolvedPath)
}
hostsByPath[resolvedPath] = append(hostsByPath[resolvedPath], host)
}
2026-04-01 21:57:05 +00:00
for _, resolvedPath := range pathOrder {
tasks, err := e.parser.ParseTasks(resolvedPath)
if err != nil {
return coreerr.E("Executor.runIncludeTasks", "include_tasks "+resolvedPath, err)
}
for _, targetHost := range hostsByPath[resolvedPath] {
for _, t := range tasks {
effectiveTask := t
effectiveTask.Vars = mergeTaskVars(task.Vars, t.Vars)
if len(effectiveTask.Vars) > 0 {
effectiveTask.Vars = e.templateArgs(effectiveTask.Vars, targetHost, task)
}
if len(task.Tags) > 0 {
effectiveTask.Tags = mergeStringSlices(task.Tags, effectiveTask.Tags)
}
if err := e.runTaskOnHosts(ctx, []string{targetHost}, &effectiveTask, play); err != nil {
return err
}
2026-04-01 21:57:05 +00:00
}
}
}
return nil
}
// runIncludeRole handles include_role/import_role.
func (e *Executor) runIncludeRole(ctx context.Context, hosts []string, task *Task, play *Play) error {
2026-04-02 00:08:24 +00:00
if len(hosts) == 0 {
return nil
}
for _, host := range hosts {
roleRef := e.resolveIncludeRoleRef(host, task)
if roleRef == nil || roleRef.Role == "" {
continue
}
if err := e.runRole(ctx, []string{host}, roleRef, play); err != nil {
return err
}
}
return nil
}
func (e *Executor) resolveIncludeRoleRef(host string, task *Task) *RoleRef {
if task == nil {
return nil
}
var ref *RoleRef
if task.IncludeRole != nil {
ref = task.IncludeRole
2026-04-02 00:08:24 +00:00
} else if task.ImportRole != nil {
ref = task.ImportRole
2026-04-02 00:08:24 +00:00
} else {
return nil
}
roleName := ref.Role
if roleName == "" {
roleName = ref.Name
}
tasksFrom := ref.TasksFrom
defaultsFrom := ref.DefaultsFrom
varsFrom := ref.VarsFrom
handlersFrom := ref.HandlersFrom
roleVars := ref.Vars
apply := ref.Apply
2026-04-02 00:08:24 +00:00
renderedVars := mergeTaskVars(roleVars, task.Vars)
if len(renderedVars) > 0 {
renderedVars = e.templateArgs(renderedVars, host, task)
}
2026-04-02 00:08:24 +00:00
return &RoleRef{
Role: e.templateString(roleName, host, task),
TasksFrom: e.templateString(tasksFrom, host, task),
DefaultsFrom: e.templateString(defaultsFrom, host, task),
VarsFrom: e.templateString(varsFrom, host, task),
HandlersFrom: e.templateString(handlersFrom, host, task),
2026-04-02 00:08:24 +00:00
Vars: renderedVars,
Apply: apply,
Public: ref.Public,
When: mergeConditions(ref.When, task.When),
Tags: mergeStringSlices(ref.Tags, task.Tags),
2026-04-02 00:08:24 +00:00
}
}
2026-04-01 22:00:12 +00:00
// mergeTaskVars combines include-task vars with child task vars.
func mergeTaskVars(parent, child map[string]any) map[string]any {
if len(parent) == 0 && len(child) == 0 {
return nil
}
merged := make(map[string]any, len(parent)+len(child))
for k, v := range parent {
merged[k] = v
}
for k, v := range child {
merged[k] = v
}
return merged
}
func mergeStringMap(parent, child map[string]string) map[string]string {
if len(parent) == 0 && len(child) == 0 {
return nil
}
merged := make(map[string]string, len(parent)+len(child))
for k, v := range parent {
merged[k] = v
}
for k, v := range child {
merged[k] = v
}
return merged
}
func (e *Executor) applyRoleTaskDefaults(task *Task, apply *TaskApply) {
if task == nil || apply == nil {
return
}
if len(apply.Tags) > 0 {
task.Tags = mergeStringSlices(apply.Tags, task.Tags)
}
if len(apply.Vars) > 0 {
task.Vars = mergeTaskVars(apply.Vars, task.Vars)
}
if len(apply.Environment) > 0 {
task.Environment = mergeStringMap(apply.Environment, task.Environment)
}
if apply.When != nil {
task.When = mergeConditions(apply.When, task.When)
}
if apply.Become != nil && task.Become == nil {
task.Become = apply.Become
}
if apply.BecomeUser != "" && task.BecomeUser == "" {
task.BecomeUser = apply.BecomeUser
}
if apply.Delegate != "" && task.Delegate == "" {
task.Delegate = apply.Delegate
}
if apply.RunOnce && !task.RunOnce {
task.RunOnce = true
}
if apply.NoLog {
task.NoLog = true
}
if apply.IgnoreErrors {
task.IgnoreErrors = true
}
}
func mergeConditions(parent, child any) any {
merged := make([]string, 0)
merged = append(merged, normalizeConditions(parent)...)
merged = append(merged, normalizeConditions(child)...)
if len(merged) == 0 {
return nil
}
return merged
}
// 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 || 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) (sshExecutorClient, error) {
// 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 {
// Executor-scoped vars include play vars and extra vars, so they must
// override inventory values when they target the same key.
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
}
2026-04-02 13:25:00 +00:00
desiredLocal := isLocalConnection(host, play, vars)
desiredBecome := play != nil && play.Become
desiredBecomeUser := ""
desiredBecomePass := ""
if desiredBecome {
desiredBecomeUser = play.BecomeUser
if bp, ok := vars["ansible_become_password"].(string); ok {
desiredBecomePass = bp
} else if cfg.Password != "" {
desiredBecomePass = cfg.Password
}
}
2026-04-01 21:19:00 +00:00
e.mu.Lock()
defer e.mu.Unlock()
if client, ok := e.clients[host]; ok {
2026-04-02 13:25:00 +00:00
switch cached := client.(type) {
case *localClient:
if desiredLocal {
cached.SetBecome(desiredBecome, desiredBecomeUser, desiredBecomePass)
return cached, nil
}
_ = cached.Close()
delete(e.clients, host)
case *SSHClient:
if !desiredLocal &&
cached.host == cfg.Host &&
cached.port == cfg.Port &&
cached.user == cfg.User &&
cached.password == cfg.Password &&
cached.keyFile == cfg.KeyFile {
cached.SetBecome(desiredBecome, desiredBecomeUser, desiredBecomePass)
return cached, nil
}
_ = cached.Close()
delete(e.clients, host)
default:
client.SetBecome(desiredBecome, desiredBecomeUser, desiredBecomePass)
2026-04-01 21:19:00 +00:00
return client, nil
}
}
2026-04-02 13:25:00 +00:00
if desiredLocal {
2026-04-01 21:19:00 +00:00
client := newLocalClient()
2026-04-02 13:25:00 +00:00
client.SetBecome(desiredBecome, desiredBecomeUser, desiredBecomePass)
2026-04-01 21:19:00 +00:00
e.clients[host] = client
return client, nil
}
// Apply play become settings
2026-04-02 13:25:00 +00:00
if desiredBecome {
cfg.Become = true
2026-04-02 13:25:00 +00:00
cfg.BecomeUser = desiredBecomeUser
cfg.BecomePass = desiredBecomePass
}
client, err := NewSSHClient(cfg)
if err != nil {
return nil, err
}
e.clients[host] = client
return client, nil
}
2026-04-01 22:03:43 +00:00
// resolveLocalPath resolves a local file path relative to the executor's base
// path when possible.
func (e *Executor) resolveLocalPath(path string) string {
if path == "" || e == nil || e.parser == nil {
return path
}
return e.parser.resolvePath(path)
}
// gatherFacts collects facts from a host.
func (e *Executor) gatherFacts(ctx context.Context, host string, play *Play) error {
client, err := e.getClient(host, play)
if err != nil {
return err
}
facts, err := e.collectFacts(ctx, client)
if err != nil {
return err
}
e.mu.Lock()
e.facts[host] = facts
e.mu.Unlock()
return nil
}
2026-04-01 21:19:00 +00:00
func isLocalConnection(host string, play *Play, vars map[string]any) bool {
if host == "localhost" {
return true
}
if play != nil && corexTrimSpace(play.Connection) == "local" {
return true
}
if conn, ok := vars["ansible_connection"].(string); ok && corexTrimSpace(conn) == "local" {
return true
}
return false
}
// evaluateWhen evaluates a when condition.
func (e *Executor) evaluateWhen(when any, host string, task *Task) bool {
return e.evaluateWhenWithLocals(when, host, task, nil)
}
func (e *Executor) evaluateWhenWithLocals(when any, host string, task *Task, locals map[string]any) bool {
conditions := normalizeConditions(when)
for _, cond := range conditions {
cond = e.templateString(cond, host, task)
if !e.evalConditionWithLocals(cond, host, task, locals) {
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 {
return e.evalConditionWithLocals(cond, host, nil, nil)
}
func (e *Executor) evalConditionWithLocals(cond string, host string, task *Task, locals map[string]any) bool {
cond = corexTrimSpace(cond)
2026-04-01 22:07:30 +00:00
if cond == "" {
return true
}
if inner, ok := stripOuterParens(cond); ok {
return e.evalConditionWithLocals(inner, host, task, locals)
}
if left, right, ok := splitLogicalCondition(cond, "or"); ok {
return e.evalConditionWithLocals(left, host, task, locals) || e.evalConditionWithLocals(right, host, task, locals)
}
if left, right, ok := splitLogicalCondition(cond, "and"); ok {
return e.evalConditionWithLocals(left, host, task, locals) && e.evalConditionWithLocals(right, host, task, locals)
}
// Handle negation
if corexHasPrefix(cond, "not ") {
return !e.evalConditionWithLocals(corexTrimPrefix(cond, "not "), host, task, locals)
}
// Handle equality/inequality
if contains(cond, "==") {
parts := splitN(cond, "==", 2)
if len(parts) == 2 {
left, leftOK := e.resolveConditionOperand(parts[0], host, task, locals)
right, rightOK := e.resolveConditionOperand(parts[1], host, task, locals)
if !leftOK || !rightOK {
return true
}
return left == right
}
}
if contains(cond, "!=") {
parts := splitN(cond, "!=", 2)
if len(parts) == 2 {
left, leftOK := e.resolveConditionOperand(parts[0], host, task, locals)
right, rightOK := e.resolveConditionOperand(parts[1], host, task, locals)
if !leftOK || !rightOK {
return true
}
return left != right
}
}
// 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 contains(cond, " is ") {
parts := splitN(cond, " is ", 2)
varName := corexTrimSpace(parts[0])
check := corexTrimSpace(parts[1])
if result, ok := e.lookupConditionValue(varName, host, task, locals); ok {
switch v := result.(type) {
case *TaskResult:
switch check {
case "defined":
return true
case "not defined", "undefined":
return false
case "success", "succeeded":
return !v.Failed
case "failed":
return v.Failed
case "changed":
return v.Changed
case "skipped":
return v.Skipped
}
case TaskResult:
switch check {
case "defined":
return true
case "not defined", "undefined":
return false
case "success", "succeeded":
return !v.Failed
case "failed":
return v.Failed
case "changed":
return v.Changed
case "skipped":
return v.Skipped
}
}
return true
}
if check == "not defined" || check == "undefined" {
return true
}
return false
}
// Handle simple var checks
if 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, ok := e.lookupConditionValue(cond, host, task, locals); ok {
switch v := result.(type) {
case *TaskResult:
return !v.Failed && !v.Skipped
case TaskResult:
return !v.Failed && !v.Skipped
case bool:
return v
case string:
return v != "" && v != "false" && v != "False"
case int:
return v != 0
case int64:
return v != 0
case float64:
return v != 0
}
}
// 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
}
2026-04-01 22:07:30 +00:00
func stripOuterParens(cond string) (string, bool) {
cond = corexTrimSpace(cond)
if len(cond) < 2 || cond[0] != '(' || cond[len(cond)-1] != ')' {
return "", false
}
depth := 0
inSingle := false
inDouble := false
escaped := false
for i := 0; i < len(cond); i++ {
ch := cond[i]
if escaped {
escaped = false
continue
}
if ch == '\\' && (inSingle || inDouble) {
escaped = true
continue
}
if ch == '\'' && !inDouble {
inSingle = !inSingle
continue
}
if ch == '"' && !inSingle {
inDouble = !inDouble
continue
}
if inSingle || inDouble {
continue
}
switch ch {
case '(':
depth++
case ')':
depth--
if depth == 0 && i != len(cond)-1 {
return "", false
}
if depth < 0 {
return "", false
}
}
}
if depth != 0 {
return "", false
}
return corexTrimSpace(cond[1 : len(cond)-1]), true
}
func splitLogicalCondition(cond, op string) (string, string, bool) {
depth := 0
inSingle := false
inDouble := false
escaped := false
for i := 0; i <= len(cond)-len(op); i++ {
ch := cond[i]
if escaped {
escaped = false
continue
}
if ch == '\\' && (inSingle || inDouble) {
escaped = true
continue
}
if ch == '\'' && !inDouble {
inSingle = !inSingle
continue
}
if ch == '"' && !inSingle {
inDouble = !inDouble
continue
}
if inSingle || inDouble {
continue
}
switch ch {
case '(':
depth++
continue
case ')':
if depth > 0 {
depth--
}
continue
}
if depth != 0 || !strings.HasPrefix(cond[i:], op) {
continue
}
if i > 0 {
prev := cond[i-1]
if !isConditionBoundary(prev) {
continue
}
}
if end := i + len(op); end < len(cond) {
if !isConditionBoundary(cond[end]) {
continue
}
}
left := corexTrimSpace(cond[:i])
right := corexTrimSpace(cond[i+len(op):])
if left == "" || right == "" {
continue
}
return left, right, true
}
return "", "", false
}
func isConditionBoundary(ch byte) bool {
switch ch {
case ' ', '\t', '\n', '\r', '(', ')':
return true
default:
return false
}
}
func (e *Executor) lookupConditionValue(name string, host string, task *Task, locals map[string]any) (any, bool) {
name = corexTrimSpace(name)
if value, ok := e.hostMagicVars(host)[name]; ok {
return value, true
}
if locals != nil {
if val, ok := locals[name]; ok {
return val, true
}
parts := splitN(name, ".", 2)
if len(parts) == 2 {
if base, ok := locals[parts[0]]; ok {
if value, ok := taskResultField(base, parts[1]); ok {
return value, true
}
}
}
}
if result := e.getRegisteredVar(host, name); result != nil {
if len(splitN(name, ".", 2)) == 2 {
parts := splitN(name, ".", 2)
if value, ok := taskResultField(result, parts[1]); ok {
return value, true
}
}
return result, true
}
if val, ok := e.vars[name]; ok {
return val, true
}
if task != nil {
if val, ok := task.Vars[name]; ok {
return val, true
}
}
2026-04-02 13:53:06 +00:00
if hostVars := e.hostScopedVars(host); hostVars != nil {
if val, ok := hostVars[name]; ok {
return val, true
}
}
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[name]; ok {
return val, true
}
}
if value, ok := e.lookupMagicScopeValue(name, host); ok {
return value, true
}
if facts, ok := e.facts[host]; ok {
if name == "ansible_facts" {
return factsToMap(facts), true
}
switch name {
case "ansible_hostname":
return facts.Hostname, true
case "ansible_fqdn":
return facts.FQDN, true
case "ansible_os_family":
return facts.OS, true
case "ansible_memtotal_mb":
return facts.Memory, true
case "ansible_processor_vcpus":
return facts.CPUs, true
case "ansible_default_ipv4_address":
return facts.IPv4, true
case "ansible_distribution":
return facts.Distribution, true
case "ansible_distribution_version":
return facts.Version, true
case "ansible_architecture":
return facts.Architecture, true
case "ansible_kernel":
return facts.Kernel, true
}
}
if contains(name, ".") {
parts := splitN(name, ".", 2)
base := parts[0]
path := parts[1]
if magic, ok := e.hostMagicVars(host)[base]; ok {
if nested, ok := lookupNestedValue(magic, path); ok {
return nested, true
}
}
if magic, ok := e.lookupMagicScopeValue(base, host); ok {
if nested, ok := lookupNestedValue(magic, path); ok {
return nested, true
}
}
if locals != nil {
if val, ok := locals[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
}
if base == "ansible_facts" {
if facts, ok := e.facts[host]; ok {
if nested, ok := lookupNestedValue(factsToMap(facts), path); ok {
return nested, true
}
}
}
if val, ok := e.vars[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
2026-04-02 13:53:06 +00:00
if hostVars := e.hostScopedVars(host); hostVars != nil {
if val, ok := hostVars[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
}
if task != nil {
if val, ok := task.Vars[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
}
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
}
}
return nil, false
}
func taskResultField(value any, field string) (any, bool) {
switch v := value.(type) {
case *TaskResult:
return taskResultField(*v, field)
case TaskResult:
switch field {
case "stdout":
return v.Stdout, true
case "stderr":
return v.Stderr, true
case "rc":
return v.RC, true
case "changed":
return v.Changed, true
case "failed":
return v.Failed, true
case "skipped":
return v.Skipped, true
case "msg":
return v.Msg, true
}
case map[string]any:
if val, ok := v[field]; ok {
return val, true
}
}
return nil, false
}
func (e *Executor) resolveConditionOperand(expr string, host string, task *Task, locals map[string]any) (string, bool) {
expr = corexTrimSpace(expr)
if expr == "true" || expr == "True" || expr == "false" || expr == "False" {
return expr, true
}
if len(expr) > 0 && expr[0] >= '0' && expr[0] <= '9' {
return expr, true
}
if (len(expr) >= 2 && expr[0] == '\'' && expr[len(expr)-1] == '\'') || (len(expr) >= 2 && expr[0] == '"' && expr[len(expr)-1] == '"') {
return expr[1 : len(expr)-1], true
}
if value, ok := e.lookupConditionValue(expr, host, task, locals); ok {
return sprintf("%v", value), true
}
return expr, false
}
func (e *Executor) applyTaskResultConditions(host string, task *Task, result *TaskResult) {
if result == nil || task == nil {
return
}
locals := map[string]any{
"result": result,
"stdout": result.Stdout,
"stderr": result.Stderr,
"rc": result.RC,
"changed": result.Changed,
"failed": result.Failed,
"skipped": result.Skipped,
"msg": result.Msg,
"duration": result.Duration,
}
if task.ChangedWhen != nil {
result.Changed = e.evaluateWhenWithLocals(task.ChangedWhen, host, task, locals)
locals["changed"] = result.Changed
locals["result"] = result
}
if task.FailedWhen != nil {
result.Failed = e.evaluateWhenWithLocals(task.FailedWhen, host, task, locals)
locals["failed"] = result.Failed
locals["result"] = result
}
}
// 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 := 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 := corexTrimSpace(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 contains(expr, " | ") {
parts := splitN(expr, " | ", 2)
value := e.resolveExpr(parts[0], host, task)
return e.applyFilter(value, parts[1])
}
// Handle lookups
if corexHasPrefix(expr, "lookup(") {
return e.handleLookup(expr, host, task)
}
// Handle registered vars
if contains(expr, ".") {
parts := 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 sprintf("%d", result.RC)
case "changed":
return sprintf("%t", result.Changed)
case "failed":
return sprintf("%t", result.Failed)
}
}
}
if value, ok := e.hostMagicVars(host)[expr]; ok {
return sprintf("%v", value)
}
// Resolve nested maps from vars, task vars, or host vars.
if contains(expr, ".") {
parts := splitN(expr, ".", 2)
if val, ok := e.lookupExprValue(parts[0], host, task); ok {
if nested, ok := lookupNestedValue(val, parts[1]); ok {
return sprintf("%v", nested)
}
}
}
// Check vars
if val, ok := e.vars[expr]; ok {
return sprintf("%v", val)
}
// Check task vars
if task != nil {
if val, ok := task.Vars[expr]; ok {
return sprintf("%v", val)
}
}
2026-04-02 13:53:06 +00:00
if hostVars := e.hostScopedVars(host); hostVars != nil {
if val, ok := hostVars[expr]; ok {
return sprintf("%v", val)
}
}
// Check host vars
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[expr]; ok {
return sprintf("%v", val)
}
}
// Check facts
if facts, ok := e.facts[host]; ok {
if expr == "ansible_facts" {
return sprintf("%v", factsToMap(facts))
}
switch expr {
case "ansible_hostname":
return facts.Hostname
case "ansible_fqdn":
return facts.FQDN
case "ansible_os_family":
return facts.OS
case "ansible_memtotal_mb":
return sprintf("%d", facts.Memory)
case "ansible_processor_vcpus":
return sprintf("%d", facts.CPUs)
case "ansible_default_ipv4_address":
return facts.IPv4
case "ansible_distribution":
return facts.Distribution
case "ansible_distribution_version":
return facts.Version
case "ansible_architecture":
return facts.Architecture
case "ansible_kernel":
return facts.Kernel
}
}
return "{{ " + expr + " }}" // Return as-is if unresolved
}
2026-04-01 20:51:57 +00:00
// buildEnvironmentPrefix renders merged play/task environment variables as
// shell export statements.
func (e *Executor) buildEnvironmentPrefix(host string, task *Task, play *Play) string {
env := make(map[string]string)
if play != nil {
for key, value := range play.Environment {
env[key] = value
}
}
if task != nil {
for key, value := range task.Environment {
env[key] = value
}
}
if len(env) == 0 {
return ""
}
keys := make([]string, 0, len(env))
for key := range env {
keys = append(keys, key)
}
slices.Sort(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
renderedKey := e.templateString(key, host, task)
if renderedKey == "" {
continue
}
renderedValue := e.templateString(env[key], host, task)
parts = append(parts, sprintf("export %s=%s", renderedKey, shellQuote(renderedValue)))
}
if len(parts) == 0 {
return ""
}
return join("; ", parts) + "; "
}
// shellQuote wraps a string in single quotes for shell use.
func shellQuote(value string) string {
return "'" + replaceAll(value, "'", "'\\''") + "'"
}
// lookupExprValue resolves the first segment of an expression against the
// executor, task, and inventory scopes.
func (e *Executor) lookupExprValue(name string, host string, task *Task) (any, bool) {
if value, ok := e.lookupMagicScopeValue(name, host); ok {
return value, true
}
if val, ok := e.vars[name]; ok {
return val, true
}
if task != nil {
if val, ok := task.Vars[name]; ok {
return val, true
}
}
2026-04-02 13:53:06 +00:00
if hostVars := e.hostScopedVars(host); hostVars != nil {
if val, ok := hostVars[name]; ok {
return val, true
}
}
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
if val, ok := hostVars[name]; ok {
return val, true
}
}
if name == "ansible_facts" {
if facts, ok := e.facts[host]; ok {
return factsToMap(facts), true
}
}
if contains(name, ".") {
parts := splitN(name, ".", 2)
base := parts[0]
path := parts[1]
if base == "ansible_facts" {
if facts, ok := e.facts[host]; ok {
if nested, ok := lookupNestedValue(factsToMap(facts), path); ok {
return nested, true
2026-04-02 13:53:06 +00:00
}
}
}
if hostVars := e.hostScopedVars(host); hostVars != nil {
if val, ok := hostVars[base]; ok {
if nested, ok := lookupNestedValue(val, path); ok {
return nested, true
}
}
}
}
return nil, false
}
func (e *Executor) lookupMagicScopeValue(name string, host string) (any, bool) {
if e == nil || e.inventory == nil {
return nil, false
}
switch name {
case "groups":
if groups := inventoryGroupHosts(e.inventory); len(groups) > 0 {
return groups, true
}
case "hostvars":
if hostvars := inventoryHostVars(e.inventory); len(hostvars) > 0 {
return hostvars, true
}
}
return nil, false
}
// lookupNestedValue walks a dotted path through nested maps.
func lookupNestedValue(value any, path string) (any, bool) {
if path == "" {
return value, true
}
current := value
for _, segment := range split(path, ".") {
switch next := current.(type) {
case map[string]any:
var ok bool
current, ok = next[segment]
if !ok {
return nil, false
}
case []any:
index, err := strconv.Atoi(segment)
if err != nil || index < 0 || index >= len(next) {
return nil, false
}
current = next[index]
case []string:
index, err := strconv.Atoi(segment)
if err != nil || index < 0 || index >= len(next) {
return nil, false
}
current = next[index]
default:
return nil, false
}
}
return current, true
}
// applyFilter applies a Jinja2 filter.
func (e *Executor) applyFilter(value, filter string) string {
filter = corexTrimSpace(filter)
// Handle default filter
if corexHasPrefix(filter, "default(") {
if value == "" || isUnresolvedTemplateValue(value) {
// Extract default value
re := regexp.MustCompile(`default\(([^)]*)\)`)
if match := re.FindStringSubmatch(filter); len(match) > 1 {
return trimCutset(match[1], "'\"")
}
}
return value
}
// Handle bool filter
if filter == "bool" {
lowered := lower(value)
if lowered == "true" || lowered == "yes" || lowered == "1" {
return "true"
}
return "false"
}
// Handle trim
if filter == "trim" {
return corexTrimSpace(value)
}
// Handle regex_replace
if corexHasPrefix(filter, "regex_replace(") {
pattern, replacement, ok := parseRegexReplaceFilter(filter)
if !ok {
return value
}
compiled, err := regexp.Compile(pattern)
if err != nil {
return value
}
return compiled.ReplaceAllString(value, replacement)
}
// Handle b64decode
if filter == "b64decode" {
2026-04-01 20:14:40 +00:00
decoded, err := base64.StdEncoding.DecodeString(value)
if err == nil {
return string(decoded)
}
if decoded, err := base64.RawStdEncoding.DecodeString(value); err == nil {
return string(decoded)
}
return value
}
// Handle b64encode
if filter == "b64encode" {
return base64.StdEncoding.EncodeToString([]byte(value))
}
return value
}
func parseRegexReplaceFilter(filter string) (string, string, bool) {
if !corexHasPrefix(filter, "regex_replace(") || !corexHasSuffix(filter, ")") {
return "", "", false
}
args := strings.TrimSpace(filter[len("regex_replace(") : len(filter)-1])
parts := splitFilterArgs(args)
if len(parts) < 2 {
return "", "", false
}
return trimCutset(parts[0], "'\""), trimCutset(parts[1], "'\""), true
}
func splitFilterArgs(args string) []string {
if args == "" {
return nil
}
var (
parts []string
current strings.Builder
inSingle bool
inDouble bool
escaped bool
)
for _, r := range args {
switch {
case escaped:
current.WriteRune(r)
escaped = false
case r == '\\' && (inSingle || inDouble):
current.WriteRune(r)
escaped = true
case r == '\'' && !inDouble:
current.WriteRune(r)
inSingle = !inSingle
case r == '"' && !inSingle:
current.WriteRune(r)
inDouble = !inDouble
case r == ',' && !inSingle && !inDouble:
part := strings.TrimSpace(current.String())
if part != "" {
parts = append(parts, part)
}
current.Reset()
default:
current.WriteRune(r)
}
}
if tail := strings.TrimSpace(current.String()); tail != "" {
parts = append(parts, tail)
}
return parts
}
func isUnresolvedTemplateValue(value string) bool {
return corexHasPrefix(value, "{{ ") && corexHasSuffix(value, " }}")
}
// handleLookup handles lookup() expressions.
func (e *Executor) handleLookup(expr string, host string, task *Task) 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 env(arg)
case "file":
2026-04-02 00:19:59 +00:00
if data, err := coreio.Local.Read(e.resolveLocalPath(arg)); err == nil {
return data
}
case "vars":
if value, ok := e.lookupConditionValue(arg, host, task, nil); ok {
return sprintf("%v", value)
}
}
return ""
}
// resolveLoop resolves loop items.
func (e *Executor) resolveLoop(loop any, host string) []any {
return e.resolveLoopWithTask(loop, host, nil)
}
func (e *Executor) resolveLoopWithTask(loop any, host string, task *Task) []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:
if items, ok := e.resolveLoopExpression(v, host, task); ok {
return items
}
// Template the string and see if it's a var reference
resolved := e.templateString(v, host, task)
if items, ok := anySliceFromValue(e.vars[resolved]); ok {
return items
}
if task != nil {
if items, ok := anySliceFromValue(task.Vars[resolved]); ok {
return items
}
}
}
return nil
}
func (e *Executor) resolveLoopExpression(loop string, host string, task *Task) ([]any, bool) {
expr, ok := extractSingleTemplateExpression(loop)
if !ok {
return nil, false
}
if value, ok := e.lookupConditionValue(expr, host, task, nil); ok {
if items, ok := anySliceFromValue(value); ok {
return items, true
}
}
return nil, false
}
func extractSingleTemplateExpression(value string) (string, bool) {
re := regexp.MustCompile(`^\s*\{\{\s*(.+?)\s*\}\}\s*$`)
match := re.FindStringSubmatch(value)
if len(match) < 2 {
return "", false
}
inner := strings.TrimSpace(match[1])
if inner == "" {
return "", false
}
return inner, true
}
func anySliceFromValue(value any) ([]any, bool) {
switch v := value.(type) {
case nil:
return nil, false
case []any:
return append([]any(nil), v...), true
case []string:
items := make([]any, len(v))
for i, item := range v {
items[i] = item
}
return items, true
}
rv := reflect.ValueOf(value)
if !rv.IsValid() {
return nil, false
}
if rv.Kind() != reflect.Slice && rv.Kind() != reflect.Array {
return nil, false
}
items := make([]any, rv.Len())
for i := 0; i < rv.Len(); i++ {
items[i] = rv.Index(i).Interface()
}
return items, true
}
// matchesTags checks if task tags match execution tags.
func (e *Executor) matchesTags(taskTags []string) bool {
// Tasks tagged "always" should run even when an explicit include filter is
// set, unless the caller explicitly skips that tag.
if slices.Contains(taskTags, "always") {
for _, skip := range e.SkipTags {
if skip == "always" {
return false
}
}
return true
}
// 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 {
if slices.Contains(taskTags, skip) {
return false
}
}
// Check include tags
if len(e.Tags) > 0 {
for _, tag := range e.Tags {
if tag == "all" || slices.Contains(taskTags, tag) {
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
}
}
}
2026-04-01 19:51:23 +00:00
// runNotifiedHandlers executes any handlers that have been notified and then
// clears the notification state for those handlers.
func (e *Executor) runNotifiedHandlers(ctx context.Context, hosts []string, play *Play) error {
if play == nil || len(play.Handlers) == 0 {
return nil
}
pending := make(map[string]bool)
for name, notified := range e.notified {
if notified {
pending[name] = true
e.notified[name] = false
}
}
if len(pending) == 0 {
return nil
}
for _, handler := range play.Handlers {
2026-04-01 22:29:44 +00:00
if handlerMatchesNotifications(&handler, pending) {
2026-04-01 19:51:23 +00:00
if err := e.runTaskOnHosts(ctx, hosts, &handler, play); err != nil {
return err
}
}
}
return nil
}
2026-04-01 22:29:44 +00:00
func handlerMatchesNotifications(handler *Task, pending map[string]bool) bool {
if handler == nil || len(pending) == 0 {
return false
}
if handler.Name != "" && pending[handler.Name] {
return true
}
for _, listen := range normalizeStringList(handler.Listen) {
if pending[listen] {
return true
}
}
return false
}
2026-04-01 19:51:23 +00:00
// handleMetaAction applies module meta side effects after the task result has
// been recorded and callbacks have fired.
func (e *Executor) handleMetaAction(ctx context.Context, host string, hosts []string, play *Play, result *TaskResult) error {
2026-04-01 19:51:23 +00:00
if result == nil || result.Data == nil {
return nil
}
action, _ := result.Data["action"].(string)
switch action {
case "flush_handlers":
return e.runNotifiedHandlers(ctx, hosts, play)
2026-04-01 20:03:11 +00:00
case "clear_facts":
e.clearFacts(hosts)
return nil
case "clear_host_errors":
e.clearHostErrors()
return nil
case "refresh_inventory":
return e.refreshInventory()
case "end_play":
return errEndPlay
2026-04-01 21:25:32 +00:00
case "end_batch":
return errEndBatch
case "end_host":
e.markHostEnded(host)
return errEndHost
case "reset_connection":
e.resetConnection(host)
return nil
2026-04-01 19:51:23 +00:00
default:
return nil
}
}
2026-04-01 20:03:11 +00:00
// clearFacts removes cached facts for the given hosts.
func (e *Executor) clearFacts(hosts []string) {
e.mu.Lock()
defer e.mu.Unlock()
for _, host := range hosts {
delete(e.facts, host)
}
}
// clearHostErrors resets the current batch failure tracking so later tasks can
// proceed after a meta clear_host_errors action.
func (e *Executor) clearHostErrors() {
e.mu.Lock()
defer e.mu.Unlock()
e.batchFailedHosts = make(map[string]bool)
}
// refreshInventory reloads inventory from the last configured source.
func (e *Executor) refreshInventory() error {
e.mu.RLock()
path := e.inventoryPath
e.mu.RUnlock()
if path == "" {
return nil
}
inv, err := e.parser.ParseInventory(path)
if err != nil {
return coreerr.E("Executor.refreshInventory", "reload inventory", err)
}
e.mu.Lock()
e.inventory = inv
e.clients = make(map[string]sshExecutorClient)
e.mu.Unlock()
return nil
}
// markHostEnded records that a host should be skipped for the rest of the play.
func (e *Executor) markHostEnded(host string) {
if host == "" {
return
}
e.mu.Lock()
defer e.mu.Unlock()
if e.endedHosts == nil {
e.endedHosts = make(map[string]bool)
}
e.endedHosts[host] = true
}
// isHostEnded reports whether a host has been retired for the current play.
func (e *Executor) isHostEnded(host string) bool {
if host == "" {
return false
}
e.mu.RLock()
defer e.mu.RUnlock()
return e.endedHosts[host]
}
// filterActiveHosts removes hosts that have already been ended.
func (e *Executor) filterActiveHosts(hosts []string) []string {
if len(hosts) == 0 {
return hosts
}
filtered := make([]string, 0, len(hosts))
for _, host := range hosts {
if !e.isHostEnded(host) {
filtered = append(filtered, host)
}
}
return filtered
}
// resetConnection closes and removes the cached SSH client for a host.
func (e *Executor) resetConnection(host string) {
if host == "" {
return
}
e.mu.Lock()
client, ok := e.clients[host]
if ok {
delete(e.clients, host)
}
e.mu.Unlock()
if ok {
_ = client.Close()
}
}
// Close closes all SSH connections.
//
// Example:
//
// exec.Close()
func (e *Executor) Close() {
e.mu.Lock()
defer e.mu.Unlock()
for _, client := range e.clients {
_ = client.Close()
}
e.clients = make(map[string]sshExecutorClient)
}
// TemplateFile processes a template file.
//
// Example:
//
// content, err := exec.TemplateFile("/workspace/templates/app.conf.j2", "web1", &Task{})
func (e *Executor) TemplateFile(src, host string, task *Task) (string, error) {
content, err := coreio.Local.Read(src)
if err != nil {
return "", err
}
// Convert Jinja2 to Go template syntax (basic conversion)
tmplContent := content
tmplContent = replaceAll(tmplContent, "{{", "{{ .")
tmplContent = replaceAll(tmplContent, "{%", "{{")
tmplContent = replaceAll(tmplContent, "%}", "}}")
tmpl, err := template.New("template").Parse(tmplContent)
if err != nil {
// Fall back to simple replacement
return e.templateString(content, host, task), nil
}
// Build context map
context := make(map[string]any)
for k, v := range e.vars {
context[k] = v
}
// Add host vars
if e.inventory != nil {
hostVars := GetHostVars(e.inventory, host)
for k, v := range hostVars {
context[k] = v
}
}
// Add facts
if facts, ok := e.facts[host]; ok {
context["ansible_facts"] = factsToMap(facts)
context["ansible_hostname"] = facts.Hostname
context["ansible_fqdn"] = facts.FQDN
context["ansible_os_family"] = facts.OS
context["ansible_memtotal_mb"] = facts.Memory
context["ansible_processor_vcpus"] = facts.CPUs
context["ansible_default_ipv4_address"] = facts.IPv4
context["ansible_distribution"] = facts.Distribution
context["ansible_distribution_version"] = facts.Version
context["ansible_architecture"] = facts.Architecture
context["ansible_kernel"] = facts.Kernel
}
buf := newBuilder()
if err := tmpl.Execute(buf, context); err != nil {
return e.templateString(content, host, task), nil
}
return buf.String(), nil
}