feat(ansible): support with_together legacy loops

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 23:38:25 +00:00
parent 2655775a8f
commit 5bb3a2f636
6 changed files with 142 additions and 32 deletions

View file

@ -661,8 +661,8 @@ func (e *Executor) runTaskOnHost(ctx context.Context, host string, hosts []strin
return coreerr.E("Executor.runTaskOnHost", sprintf("get client for %s", executionHost), err)
}
// Handle loops, including legacy with_file, with_fileglob, and with_sequence syntax.
if task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil {
// Handle loops, including legacy with_file, with_fileglob, with_sequence, and with_together syntax.
if task.Loop != nil || task.WithFile != nil || task.WithFileGlob != nil || task.WithSequence != nil || task.WithTogether != nil {
return e.runLoop(ctx, host, client, task, play, start)
}
@ -864,6 +864,8 @@ func (e *Executor) runLoop(ctx context.Context, host string, client sshExecutorC
items, err = e.resolveWithFileGlobLoop(task.WithFileGlob, host, task)
} 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 {
items = e.resolveLoop(task.Loop, host)
}
@ -1141,6 +1143,15 @@ func (e *Executor) resolveWithSequenceLoop(loop any, host string, task *Task) ([
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 parseSequenceSpec(loop any) (*sequenceSpec, error) {
spec := &sequenceSpec{
step: 1,

View file

@ -919,6 +919,33 @@ func TestExecutor_RunTaskOnHost_Good_LoopFromWithNested(t *testing.T) {
assert.Equal(t, "blue-large", result.Results[3].Msg)
}
func TestExecutor_RunTaskOnHost_Good_LoopFromWithTogether(t *testing.T) {
e := NewExecutor("/tmp")
e.clients["host1"] = NewMockSSHClient()
task := &Task{
Name: "Together loop",
Module: "debug",
Args: map[string]any{
"msg": "{{ item.0 }}={{ item.1 }}",
},
WithTogether: []any{
[]any{"red", "blue"},
[]any{"small", "large", "medium"},
},
Register: "together_loop_result",
}
err := e.runTaskOnHosts(context.Background(), []string{"host1"}, task, &Play{})
require.NoError(t, err)
result := e.results["host1"]["together_loop_result"]
require.NotNil(t, result)
require.Len(t, result.Results, 2)
assert.Equal(t, "red=small", result.Results[0].Msg)
assert.Equal(t, "blue=large", result.Results[1].Msg)
}
func TestExecutor_RunTaskOnHosts_Good_LoopNotifiesAndCallsCallback(t *testing.T) {
e := NewExecutor("/tmp")
e.clients["host1"] = NewMockSSHClient()

29
go.sum
View file

@ -4,57 +4,28 @@ dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
forge.lthn.ai/core/go v0.3.0/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-crypt v0.1.6/go.mod h1:4VZAGqxlbadhSB66sJkdj54/HSJ+bSxVgwWK5kMMYDo=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

View file

@ -357,7 +357,7 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
"action": true, "local_action": true,
"include_tasks": true, "import_tasks": true,
"include_role": true, "import_role": true,
"with_items": true, "with_dict": true, "with_indexed_items": true, "with_nested": true, "with_file": true, "with_fileglob": true, "with_sequence": true,
"with_items": true, "with_dict": true, "with_indexed_items": true, "with_nested": true, "with_together": true, "with_file": true, "with_fileglob": true, "with_sequence": true,
}
for key, val := range m {
@ -462,6 +462,11 @@ func (t *Task) UnmarshalYAML(node *yaml.Node) error {
t.WithSequence = sequence
}
// Preserve with_together so the executor can zip legacy loop inputs at runtime.
if t.WithTogether != nil && t.Loop == nil {
t.Loop = expandTogetherLoop(t.WithTogether)
}
// Support legacy action/local_action shorthands.
if t.Module == "" {
if localAction, ok := m["local_action"]; ok {
@ -582,6 +587,35 @@ func expandNestedLoop(loop any) []any {
return items
}
// expandTogetherLoop converts with_together input into a zipped loop. Each
// output item contains one value from each input group at the same index.
func expandTogetherLoop(loop any) []any {
groups, ok := togetherLoopGroups(loop)
if !ok || len(groups) == 0 {
return nil
}
minLen := len(groups[0])
for _, group := range groups[1:] {
if len(group) < minLen {
minLen = len(group)
}
}
if minLen == 0 {
return nil
}
items := make([]any, 0, minLen)
for i := 0; i < minLen; i++ {
combo := make([]any, len(groups))
for j, group := range groups {
combo[j] = group[i]
}
items = append(items, combo)
}
return items
}
func nestedLoopGroups(loop any) ([][]any, bool) {
switch v := loop.(type) {
case []any:
@ -638,6 +672,41 @@ func nestedLoopItems(value any) []any {
}
}
func togetherLoopGroups(loop any) ([][]any, bool) {
switch v := loop.(type) {
case []any:
groups := make([][]any, 0, len(v))
for _, group := range v {
items := nestedLoopItems(group)
if len(items) == 0 {
return nil, false
}
groups = append(groups, items)
}
return groups, true
case []string:
items := make([]any, len(v))
for i, item := range v {
items[i] = item
}
if len(items) == 0 {
return nil, false
}
return [][]any{items}, true
case string:
if v == "" {
return nil, false
}
return [][]any{{v}}, true
default:
items := nestedLoopItems(v)
if len(items) == 0 {
return nil, false
}
return [][]any{items}, true
}
}
// isModule checks if a key is a known module.
func isModule(key string) bool {
for _, m := range KnownModules {

View file

@ -121,6 +121,7 @@ type Task struct {
WithFile any `yaml:"with_file,omitempty"`
WithFileGlob any `yaml:"with_fileglob,omitempty"`
WithSequence any `yaml:"with_sequence,omitempty"`
WithTogether any `yaml:"with_together,omitempty"`
IncludeRole *struct {
Name string `yaml:"name"`
TasksFrom string `yaml:"tasks_from,omitempty"`

View file

@ -369,6 +369,37 @@ with_nested:
assert.Equal(t, []any{"blue", "large"}, fourth)
}
func TestTypes_Task_UnmarshalYAML_Good_WithTogether(t *testing.T) {
input := `
name: Together loop values
debug:
msg: "{{ item.0 }} {{ item.1 }}"
with_together:
- - red
- blue
- - small
- large
- medium
`
var task Task
err := yaml.Unmarshal([]byte(input), &task)
require.NoError(t, err)
require.NotNil(t, task.WithTogether)
items, ok := task.Loop.([]any)
require.True(t, ok)
require.Len(t, items, 2)
first, ok := items[0].([]any)
require.True(t, ok)
assert.Equal(t, []any{"red", "small"}, first)
second, ok := items[1].([]any)
require.True(t, ok)
assert.Equal(t, []any{"blue", "large"}, second)
}
func TestTypes_Task_UnmarshalYAML_Good_WithNotify(t *testing.T) {
input := `
name: Install package