Skip to content

Commit 2541c4e

Browse files
authored
feat!: Use shell for running command execution (#745)
1 parent 49a0ea4 commit 2541c4e

17 files changed

+813
-422
lines changed

README.md

+240-201
Large diffs are not rendered by default.

docs/source/yaml_format.rst

+25-10
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Before diving into specific features, let's understand the basic structure of a
1616

1717
Minimal Example
1818
~~~~~~~~~~~~~~
19-
A DAG consists of one or more steps, each with a name and command. Here's the simplest possible DAG:
19+
A DAG with two steps:
2020

2121
.. code-block:: yaml
2222
@@ -28,13 +28,22 @@ A DAG consists of one or more steps, each with a name and command. Here's the si
2828
depends:
2929
- step 1
3030
31-
The command can be a string or list of strings. The list format is useful when passing arguments:
31+
Using a pipe:
3232

3333
.. code-block:: yaml
3434
3535
steps:
3636
- name: step 1
37-
command: [echo, hello]
37+
command: echo hello world | xargs echo
38+
39+
Specifying a shell:
40+
41+
.. code-block:: yaml
42+
43+
steps:
44+
- name: step 1
45+
command: echo hello world | xargs echo
46+
shell: bash
3847
3948
Schema Definition
4049
~~~~~~~~~~~~~~~~
@@ -101,22 +110,28 @@ Use named parameters for better clarity:
101110
102111
Code Snippets
103112
~~~~~~~~~~~~
104-
Run inline scripts in any language:
113+
114+
Run shell script with `$SHELL`:
105115

106116
.. code-block:: yaml
107117
108118
steps:
109119
- name: script step
110-
command: "bash"
111120
script: |
112121
cd /tmp
113122
echo "hello world" > hello
114123
cat hello
115-
output: RESULT
116-
- name: use result
117-
command: echo ${RESULT}
118-
depends:
119-
- script step
124+
125+
You can run arbitrary script with the `script` field. The script will be executed with the program specified in the `command` field. If `command` is not specified, the default shell will be used.
126+
127+
.. code-block:: yaml
128+
129+
steps:
130+
- name: script step
131+
command: python
132+
script: |
133+
import os
134+
print(os.getcwd())
120135
121136
Output Handling
122137
--------------

internal/cmdutil/cmdutil.go

+231
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
// Copyright (C) 2024 Yota Hamada
2+
// SPDX-License-Identifier: GPL-3.0-or-later
3+
4+
package cmdutil
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"os/exec"
10+
"regexp"
11+
"strings"
12+
"unicode"
13+
14+
"github.com/mattn/go-shellwords"
15+
)
16+
17+
var ErrCommandIsEmpty = fmt.Errorf("command is empty")
18+
19+
// ParsePipedCommand splits a shell-style command string into a pipeline ([][]string).
20+
// Each sub-slice represents a single command. Unquoted "|" tokens define the boundaries.
21+
//
22+
// Example:
23+
//
24+
// parsePipedCommand(`echo foo | grep foo | wc -l`) =>
25+
// [][]string{
26+
// {"echo", "foo"},
27+
// {"grep", "foo"},
28+
// {"wc", "-l"},
29+
// }
30+
//
31+
// parsePipedCommand(`echo "hello|world"`) =>
32+
// [][]string{ {"echo", "hello|world"} } // single command
33+
func ParsePipedCommand(cmdString string) ([][]string, error) {
34+
var inQuote, inBacktick, inEscape bool
35+
var current []rune
36+
var tokens []string
37+
38+
for _, r := range cmdString {
39+
switch {
40+
case inEscape:
41+
current = append(current, r)
42+
inEscape = false
43+
case r == '\\':
44+
current = append(current, r)
45+
inEscape = true
46+
case r == '"' && !inBacktick:
47+
current = append(current, r)
48+
inQuote = !inQuote
49+
case r == '`':
50+
current = append(current, r)
51+
inBacktick = !inBacktick
52+
case r == '|' && !inQuote && !inBacktick:
53+
if len(current) > 0 {
54+
tokens = append(tokens, string(current))
55+
current = nil
56+
}
57+
tokens = append(tokens, "|")
58+
case unicode.IsSpace(r) && !inQuote && !inBacktick:
59+
if len(current) > 0 {
60+
tokens = append(tokens, string(current))
61+
current = nil
62+
}
63+
default:
64+
current = append(current, r)
65+
}
66+
}
67+
68+
if len(current) > 0 {
69+
tokens = append(tokens, string(current))
70+
}
71+
72+
var pipeline [][]string
73+
var currentCmd []string
74+
75+
for _, token := range tokens {
76+
if token == "|" {
77+
if len(currentCmd) > 0 {
78+
pipeline = append(pipeline, currentCmd)
79+
currentCmd = nil
80+
}
81+
} else {
82+
currentCmd = append(currentCmd, token)
83+
}
84+
}
85+
86+
if len(currentCmd) > 0 {
87+
pipeline = append(pipeline, currentCmd)
88+
}
89+
90+
return pipeline, nil
91+
}
92+
93+
func SplitCommandWithEval(cmd string) (string, []string, error) {
94+
pipeline, err := ParsePipedCommand(cmd)
95+
if err != nil {
96+
return "", nil, err
97+
}
98+
99+
parser := shellwords.NewParser()
100+
parser.ParseBacktick = true
101+
parser.ParseEnv = false
102+
103+
for _, command := range pipeline {
104+
if len(command) < 2 {
105+
continue
106+
}
107+
for i, arg := range command {
108+
// Expand environment variables in the command.
109+
command[i] = os.ExpandEnv(arg)
110+
// escape the command
111+
command[i] = escapeReplacer.Replace(command[i])
112+
// Substitute command in the command.
113+
command[i], err = SubstituteCommands(command[i])
114+
if err != nil {
115+
return "", nil, fmt.Errorf("failed to substitute command: %w", err)
116+
}
117+
// unescape the command
118+
// command[i] = unescapeReplacer.Replace(command[i])
119+
}
120+
}
121+
122+
if len(pipeline) > 1 {
123+
first := pipeline[0]
124+
cmd := first[0]
125+
args := first[1:]
126+
for _, command := range pipeline[1:] {
127+
args = append(args, "|")
128+
args = append(args, command...)
129+
}
130+
return cmd, args, nil
131+
}
132+
133+
if len(pipeline) == 0 {
134+
return "", nil, ErrCommandIsEmpty
135+
}
136+
137+
command := pipeline[0]
138+
if len(command) == 0 {
139+
return "", nil, ErrCommandIsEmpty
140+
}
141+
142+
return command[0], command[1:], nil
143+
}
144+
145+
var (
146+
escapeReplacer = strings.NewReplacer(
147+
`\t`, `\\\\t`,
148+
`\r`, `\\\\r`,
149+
`\n`, `\\\\n`,
150+
)
151+
)
152+
153+
func SplitCommand(cmd string) (string, []string, error) {
154+
pipeline, err := ParsePipedCommand(cmd)
155+
if err != nil {
156+
return "", nil, err
157+
}
158+
159+
if len(pipeline) > 1 {
160+
first := pipeline[0]
161+
cmd := first[0]
162+
args := first[1:]
163+
for _, command := range pipeline[1:] {
164+
args = append(args, "|")
165+
args = append(args, command...)
166+
}
167+
return cmd, args, nil
168+
}
169+
170+
if len(pipeline) == 0 {
171+
return "", nil, ErrCommandIsEmpty
172+
}
173+
174+
command := pipeline[0]
175+
if len(command) == 0 {
176+
return "", nil, ErrCommandIsEmpty
177+
}
178+
179+
return command[0], command[1:], nil
180+
}
181+
182+
// tickerMatcher matches the command in the value string.
183+
// Example: "`date`"
184+
var tickerMatcher = regexp.MustCompile("`[^`]+`")
185+
186+
// SubstituteCommands substitutes command in the value string.
187+
// This logic needs to be refactored to handle more complex cases.
188+
func SubstituteCommands(input string) (string, error) {
189+
matches := tickerMatcher.FindAllString(strings.TrimSpace(input), -1)
190+
if matches == nil {
191+
return input, nil
192+
}
193+
194+
ret := input
195+
for i := 0; i < len(matches); i++ {
196+
// Execute the command and replace the command with the output.
197+
command := matches[i]
198+
199+
parser := shellwords.NewParser()
200+
parser.ParseBacktick = true
201+
parser.ParseEnv = false
202+
203+
res, err := parser.Parse(escapeReplacer.Replace(command))
204+
if err != nil {
205+
return "", fmt.Errorf("failed to substitute command: %w", err)
206+
}
207+
208+
ret = strings.ReplaceAll(ret, command, strings.Join(res, " "))
209+
}
210+
211+
return ret, nil
212+
}
213+
214+
// GetShellCommand returns the shell to use for command execution
215+
func GetShellCommand(configuredShell string) string {
216+
if configuredShell != "" {
217+
return configuredShell
218+
}
219+
220+
// Try system shell first
221+
if systemShell := os.ExpandEnv("${SHELL}"); systemShell != "" {
222+
return systemShell
223+
}
224+
225+
// Fallback to sh if available
226+
if shPath, err := exec.LookPath("sh"); err == nil {
227+
return shPath
228+
}
229+
230+
return ""
231+
}

0 commit comments

Comments
 (0)