From 5bb3a2f636ea18f080a16b51c2648f10c78f3a24 Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 23:38:25 +0000 Subject: [PATCH] feat(ansible): support with_together legacy loops Co-Authored-By: Virgil --- executor.go | 15 ++++++++-- executor_test.go | 27 ++++++++++++++++++ go.sum | 29 -------------------- parser.go | 71 +++++++++++++++++++++++++++++++++++++++++++++++- types.go | 1 + types_test.go | 31 +++++++++++++++++++++ 6 files changed, 142 insertions(+), 32 deletions(-) diff --git a/executor.go b/executor.go index 96b1484..f12ede9 100644 --- a/executor.go +++ b/executor.go @@ -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, diff --git a/executor_test.go b/executor_test.go index 32a3ee6..f21c9a7 100644 --- a/executor_test.go +++ b/executor_test.go @@ -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() diff --git a/go.sum b/go.sum index 6051d8b..47503e9 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/parser.go b/parser.go index 4fc2234..9a35c68 100644 --- a/parser.go +++ b/parser.go @@ -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 { diff --git a/types.go b/types.go index 44f225c..409aecc 100644 --- a/types.go +++ b/types.go @@ -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"` diff --git a/types_test.go b/types_test.go index 4ae0f31..b9bce42 100644 --- a/types_test.go +++ b/types_test.go @@ -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