Skip to content

Commit d7aa85e

Browse files
committed
add support for shell execution in script, add script call render
1 parent 6647fbb commit d7aa85e

File tree

4 files changed

+206
-39
lines changed

4 files changed

+206
-39
lines changed

README.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ Check examples in:
117117
**absolute minimal example of manifest:**
118118

119119
```yaml
120-
{}
120+
{ }
121121
```
122122

123123
Yes, empty object is valid manifest.
@@ -220,6 +220,42 @@ after:
220220

221221
Rules of rendering value in `default` section is the same as in [`computed`](#computed).
222222

223+
### Hooks
224+
225+
Hooks can be defined through inline portable shell or through templated script.
226+
227+
* `before` hooks executed with resolved state (after user input and computed variables), before rendering paths and
228+
content
229+
* `after` hooks executed after content rendered
230+
231+
Optionally, a `label` could be defined to show human-friendly text during execution.
232+
233+
Working directory for script and inline always inside destination directory. For script invocation, path to script is
234+
relative to layout content.
235+
236+
Example:
237+
238+
```yaml
239+
#...
240+
before:
241+
# inline script
242+
- label: Save current date
243+
run: date > created.txt
244+
after:
245+
# file script
246+
- label: Say hello
247+
script: hooks/hello.sh "{{.dirname}}"
248+
#...
249+
```
250+
251+
Content of `hooks/hello.sh` could be (`foo` should be defined):
252+
253+
```shell
254+
#!/bin/sh
255+
256+
wall Hello "{{.foo}}" "$1"
257+
```
258+
223259
### Helpers
224260

225261
#### Tengo functions

internal/runnable.go

Lines changed: 72 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import (
2121
"fmt"
2222
"io/ioutil"
2323
"os"
24-
"os/exec"
2524
"path"
2625
"path/filepath"
2726
"strings"
@@ -32,20 +31,19 @@ import (
3231

3332
// execute hook as script (priority) or inline shell. Shell is platform-independent, thanks to mvdan.cc/sh.
3433
func (h Runnable) execute(ctx context.Context, state map[string]interface{}, workDir string, layoutFS string) error {
35-
if h.Script != "" {
36-
return h.executeScript(ctx, state, workDir, layoutFS)
37-
}
38-
return h.executeInline(ctx, state, workDir)
39-
}
40-
41-
// execute inline (run) shell script.
42-
func (h Runnable) executeInline(ctx context.Context, state map[string]interface{}, workDir string) error {
4334
cp, err := h.render(state)
4435
if err != nil {
4536
return fmt.Errorf("render hook: %w", err)
4637
}
38+
if cp.Script != "" {
39+
return cp.executeScript(ctx, state, workDir, layoutFS)
40+
}
41+
return cp.executeInline(ctx, state, workDir)
42+
}
4743

48-
script, err := syntax.NewParser().Parse(strings.NewReader(cp.Run), "")
44+
// execute inline (run) shell script.
45+
func (h Runnable) executeInline(ctx context.Context, state map[string]interface{}, workDir string) error {
46+
script, err := syntax.NewParser().Parse(strings.NewReader(h.Run), "")
4947
if err != nil {
5048
return fmt.Errorf("parse script: %w", err)
5149
}
@@ -59,52 +57,76 @@ func (h Runnable) executeInline(ctx context.Context, state map[string]interface{
5957
}
6058

6159
// render script to temporary file and execute it. Automatically sets +x (executable) flag to file.
60+
// It CAN support more or less complex shell execution, however, it designed for direct script invocation: <script> [args...]
6261
func (h Runnable) executeScript(ctx context.Context, state map[string]interface{}, workDir string, layoutFS string) error {
63-
scriptContent, err := ioutil.ReadFile(filepath.Join(layoutFS, path.Clean(h.Script)))
62+
parsedCommand, err := syntax.NewParser().Parse(strings.NewReader(h.Script), "")
6463
if err != nil {
65-
return fmt.Errorf("read hook script content: %w", err)
64+
return fmt.Errorf("parse script invokation: %w", err)
6665
}
67-
68-
newScriptContent, err := render(string(scriptContent), state)
69-
if err != nil {
70-
return fmt.Errorf("render hook script content: %w", err)
66+
var callExpr *syntax.CallExpr
67+
for _, stmt := range parsedCommand.Stmts {
68+
if call, ok := stmt.Cmd.(*syntax.CallExpr); ok && len(call.Args) > 0 {
69+
callExpr = call
70+
break
71+
}
7172
}
7273

73-
f, err := os.CreateTemp("", "")
74-
if err != nil {
75-
return fmt.Errorf("create temp file: %w", err)
76-
}
77-
defer os.RemoveAll(f.Name())
78-
defer f.Close()
74+
if callExpr != nil {
75+
// render script content and copy it to temp dir
7976

80-
if _, err := f.WriteString(newScriptContent); err != nil {
81-
return fmt.Errorf("write rendered hook content: %w", err)
82-
}
77+
scriptContent, err := ioutil.ReadFile(filepath.Join(layoutFS, path.Clean(assemblePathToCommand(callExpr))))
78+
if err != nil {
79+
return fmt.Errorf("read hook script content: %w", err)
80+
}
8381

84-
if err := f.Close(); err != nil {
85-
return fmt.Errorf("close script: %w", err)
86-
}
82+
newScriptContent, err := render(string(scriptContent), state)
83+
if err != nil {
84+
return fmt.Errorf("render hook script content: %w", err)
85+
}
86+
87+
f, err := os.CreateTemp("", "")
88+
if err != nil {
89+
return fmt.Errorf("create temp file: %w", err)
90+
}
91+
defer os.RemoveAll(f.Name())
92+
defer f.Close()
93+
94+
if _, err := f.WriteString(newScriptContent); err != nil {
95+
return fmt.Errorf("write rendered hook content: %w", err)
96+
}
97+
98+
if err := f.Close(); err != nil {
99+
return fmt.Errorf("close script: %w", err)
100+
}
87101

88-
if err := os.Chmod(f.Name(), 0700); err != nil {
89-
return fmt.Errorf("mark script as executable: %w", err)
102+
if err := os.Chmod(f.Name(), 0700); err != nil {
103+
return fmt.Errorf("mark script as executable: %w", err)
104+
}
105+
106+
// mock call in expression
107+
callExpr.Args[0].Parts[0] = &syntax.Lit{Value: f.Name()}
90108
}
91109

92-
cmd := exec.CommandContext(ctx, f.Name())
93-
cmd.Stdout = os.Stdout
94-
cmd.Stderr = os.Stderr
95-
cmd.Dir = workDir
110+
runner, err := interp.New(interp.Dir(workDir), interp.StdIO(nil, os.Stdout, os.Stderr))
111+
if err != nil {
112+
return fmt.Errorf("create script runner: %w", err)
113+
}
96114

97-
return cmd.Run()
115+
return runner.Run(ctx, parsedCommand)
98116
}
99117

100-
// render templated variables: run
118+
// render templated variables: run, script
101119
func (h Runnable) render(state map[string]interface{}) (Runnable, error) {
102120
if v, err := render(h.Run, state); err != nil {
103121
return h, fmt.Errorf("render run: %w", err)
104122
} else {
105123
h.Run = v
106124
}
107-
125+
if v, err := render(h.Script, state); err != nil {
126+
return h, fmt.Errorf("render script: %w", err)
127+
} else {
128+
h.Script = v
129+
}
108130
return h, nil
109131
}
110132

@@ -115,3 +137,16 @@ func (h Runnable) what() string {
115137
}
116138
return h.Run
117139
}
140+
141+
func assemblePathToCommand(stmt *syntax.CallExpr) string {
142+
var ans []string = make([]string, 0, len(stmt.Args[0].Parts))
143+
for _, p := range stmt.Args[0].Parts {
144+
switch v := p.(type) {
145+
case *syntax.SglQuoted:
146+
ans = append(ans, v.Value)
147+
case *syntax.Lit:
148+
ans = append(ans, v.Value)
149+
}
150+
}
151+
return strings.Join(ans, "")
152+
}

internal/runnable_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
Copyright 2022 Aleksandr Baryshnikov
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package internal
18+
19+
import (
20+
"context"
21+
"io/ioutil"
22+
"os"
23+
"path/filepath"
24+
"strings"
25+
"testing"
26+
27+
"github.com/stretchr/testify/require"
28+
)
29+
30+
func TestRunnable(t *testing.T) {
31+
tmpDir, err := os.MkdirTemp("", "")
32+
require.NoError(t, err)
33+
defer os.RemoveAll(tmpDir)
34+
35+
ctx := context.Background()
36+
state := map[string]interface{}{
37+
"foo": 123,
38+
"bar": "baz",
39+
}
40+
41+
t.Run("inline run should work", func(t *testing.T) {
42+
run := Runnable{
43+
Run: "echo -n {{.foo}} > inline.txt",
44+
}
45+
err := run.execute(ctx, state, tmpDir, "")
46+
require.NoError(t, err)
47+
require.FileExists(t, filepath.Join(tmpDir, "inline.txt"))
48+
requireContent(t, "123", filepath.Join(tmpDir, "inline.txt"))
49+
})
50+
51+
t.Run("simple hook should work", func(t *testing.T) {
52+
hooksDir, err := os.MkdirTemp("", "")
53+
require.NoError(t, err)
54+
defer os.RemoveAll(hooksDir)
55+
56+
err = ioutil.WriteFile(filepath.Join(hooksDir, "h o o k.sh"), []byte(strings.TrimSpace(`
57+
#!/bin/sh
58+
echo -n {{.foo}} > hook.txt
59+
`)), 0755)
60+
require.NoError(t, err)
61+
62+
run := Runnable{
63+
Script: "'h o o k.sh'",
64+
}
65+
err = run.execute(ctx, state, tmpDir, hooksDir)
66+
require.NoError(t, err)
67+
require.FileExists(t, filepath.Join(tmpDir, "hook.txt"))
68+
requireContent(t, "123", filepath.Join(tmpDir, "hook.txt"))
69+
})
70+
71+
t.Run("hook with args should work", func(t *testing.T) {
72+
hooksDir, err := os.MkdirTemp("", "")
73+
require.NoError(t, err)
74+
defer os.RemoveAll(hooksDir)
75+
76+
err = ioutil.WriteFile(filepath.Join(hooksDir, "hook.sh"), []byte(strings.TrimSpace(`
77+
#!/bin/sh
78+
echo -n "$1" > hook2.txt
79+
`)), 0755)
80+
require.NoError(t, err)
81+
82+
run := Runnable{
83+
Script: "hook.sh '{{.foo}}'",
84+
}
85+
err = run.execute(ctx, state, tmpDir, hooksDir)
86+
require.NoError(t, err)
87+
require.FileExists(t, filepath.Join(tmpDir, "hook2.txt"))
88+
requireContent(t, "123", filepath.Join(tmpDir, "hook2.txt"))
89+
})
90+
}
91+
92+
func requireContent(t *testing.T, expected string, fileName string) {
93+
d, err := ioutil.ReadFile(fileName)
94+
require.NoError(t, err)
95+
require.Equal(t, expected, string(d))
96+
}

internal/types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ type Hook struct {
6868

6969
type Runnable struct {
7070
Run string // templated, shell like (mvdan.cc/sh)
71-
Script string // path to script (executable), relative to manifest, content templated
71+
Script string // path to script (executable), relative to manifest, content templated. It has limited support for shell execution, and designed for direct script invocation: <script> [args...]
7272
}
7373

7474
type VarType string

0 commit comments

Comments
 (0)