summaryrefslogtreecommitdiff
path: root/plugins.go
blob: 3e6e1f046b7c13202e501c2c58382ff541fb86af (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
package gomplate

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"runtime"
	"text/template"
	"time"

	"github.com/hairyhenderson/gomplate/v3/conv"
	"github.com/hairyhenderson/gomplate/v3/internal/config"
)

func bindPlugins(ctx context.Context, cfg *config.Config, funcMap template.FuncMap) error {
	for k, v := range cfg.Plugins {
		timeout := cfg.PluginTimeout
		if v.Timeout != 0 {
			timeout = v.Timeout
		}

		plugin := &plugin{
			ctx:     ctx,
			name:    k,
			path:    v.Cmd,
			timeout: timeout,
			pipe:    v.Pipe,
			stderr:  cfg.Stderr,
		}
		if _, ok := funcMap[plugin.name]; ok {
			return fmt.Errorf("function %q is already bound, and can not be overridden", plugin.name)
		}
		funcMap[plugin.name] = plugin.run
	}
	return nil
}

// plugin represents a custom function that binds to an external process to be executed
type plugin struct {
	ctx        context.Context
	stderr     io.Writer
	name, path string
	timeout    time.Duration
	pipe       bool
}

// builds a command that's appropriate for running scripts
// nolint: gosec
func (p *plugin) buildCommand(a []string) (name string, args []string) {
	switch filepath.Ext(p.path) {
	case ".ps1":
		a = append([]string{"-File", p.path}, a...)
		return findPowershell(), a
	case ".cmd", ".bat":
		a = append([]string{"/c", p.path}, a...)
		return "cmd.exe", a
	default:
		return p.path, a
	}
}

// finds the appropriate powershell command for the platform - prefers
// PowerShell Core (`pwsh`), but on Windows if it's not found falls back to
// Windows PowerShell (`powershell`).
func findPowershell() string {
	if runtime.GOOS != "windows" {
		return "pwsh"
	}

	_, err := exec.LookPath("pwsh")
	if err != nil {
		return "powershell"
	}
	return "pwsh"
}

func (p *plugin) run(args ...interface{}) (interface{}, error) {
	a := conv.ToStrings(args...)

	name, a := p.buildCommand(a)

	ctx, cancel := context.WithTimeout(p.ctx, p.timeout)
	defer cancel()

	var stdin *bytes.Buffer
	if p.pipe && len(a) > 0 {
		stdin = bytes.NewBufferString(a[len(a)-1])
		a = a[:len(a)-1]
	}

	c := exec.CommandContext(ctx, name, a...)
	if stdin != nil {
		c.Stdin = stdin
	}

	c.Stderr = p.stderr
	outBuf := &bytes.Buffer{}
	c.Stdout = outBuf

	start := time.Now()
	err := c.Start()
	if err != nil {
		return nil, err
	}

	// make sure all signals are propagated
	sigs := make(chan os.Signal, 1)
	signal.Notify(sigs)
	go func() {
		select {
		case sig := <-sigs:
			// Pass signals to the sub-process
			if c.Process != nil {
				// nolint: gosec
				_ = c.Process.Signal(sig)
			}
		case <-ctx.Done():
		}
	}()

	err = c.Wait()
	elapsed := time.Since(start)

	if ctx.Err() != nil {
		err = fmt.Errorf("plugin timed out after %v: %w", elapsed, ctx.Err())
	}

	return outBuf.String(), err
}