github.com/hairyhenderson/gomplate/v4@v4.0.0-pre-2.0.20240520121557-362f058f0c93/plugins.go (about) 1 package gomplate 2 3 import ( 4 "bytes" 5 "context" 6 "fmt" 7 "io" 8 "os" 9 "os/exec" 10 "os/signal" 11 "path/filepath" 12 "runtime" 13 "text/template" 14 "time" 15 16 "github.com/hairyhenderson/gomplate/v4/conv" 17 "github.com/hairyhenderson/gomplate/v4/internal/config" 18 ) 19 20 // bindPlugins creates custom plugin functions for each plugin specified by 21 // the config, and adds them to the given funcMap. Uses the configuration's 22 // PluginTimeout as the default plugin Timeout. Errors if a function name is 23 // duplicated. 24 func bindPlugins(ctx context.Context, cfg *config.Config, funcMap template.FuncMap) error { 25 for k, v := range cfg.Plugins { 26 if _, ok := funcMap[k]; ok { 27 return fmt.Errorf("function %q is already bound, and can not be overridden", k) 28 } 29 30 // default the timeout to the one in the config 31 timeout := cfg.PluginTimeout 32 if v.Timeout != 0 { 33 timeout = v.Timeout 34 } 35 36 funcMap[k] = PluginFunc(ctx, v.Cmd, PluginOpts{ 37 Timeout: timeout, 38 Pipe: v.Pipe, 39 Stderr: cfg.Stderr, 40 Args: v.Args, 41 }) 42 } 43 44 return nil 45 } 46 47 // PluginOpts are options for controlling plugin function execution 48 type PluginOpts struct { 49 // Stderr can be set to redirect the plugin's stderr to a custom writer. 50 // Defaults to os.Stderr. 51 Stderr io.Writer 52 53 // Args are additional arguments to pass to the plugin. These precede any 54 // arguments passed to the plugin function at runtime. 55 Args []string 56 57 // Timeout is the maximum amount of time to wait for the plugin to complete. 58 // Defaults to 5 seconds. 59 Timeout time.Duration 60 61 // Pipe indicates whether the last argument should be piped to the plugin's 62 // stdin (true) or processed as a commandline argument (false) 63 Pipe bool 64 } 65 66 // PluginFunc creates a template function that runs an external process - either 67 // a shell script or commandline executable. 68 func PluginFunc(ctx context.Context, cmd string, opts PluginOpts) func(...interface{}) (interface{}, error) { 69 timeout := opts.Timeout 70 if timeout == 0 { 71 timeout = 5 * time.Second 72 } 73 74 stderr := opts.Stderr 75 if stderr == nil { 76 stderr = os.Stderr 77 } 78 79 plugin := &plugin{ 80 ctx: ctx, 81 path: cmd, 82 args: opts.Args, 83 timeout: timeout, 84 pipe: opts.Pipe, 85 stderr: stderr, 86 } 87 88 return plugin.run 89 } 90 91 // plugin represents a custom function that binds to an external process to be executed 92 type plugin struct { 93 ctx context.Context 94 stderr io.Writer 95 path string 96 args []string 97 timeout time.Duration 98 pipe bool 99 } 100 101 // builds a command that's appropriate for running scripts 102 func (p *plugin) buildCommand(a []string) (name string, args []string) { 103 switch filepath.Ext(p.path) { 104 case ".ps1": 105 a = append([]string{"-File", p.path}, a...) 106 return findPowershell(), a 107 case ".cmd", ".bat": 108 a = append([]string{"/c", p.path}, a...) 109 return "cmd.exe", a 110 default: 111 return p.path, a 112 } 113 } 114 115 // finds the appropriate powershell command for the platform - prefers 116 // PowerShell Core (`pwsh`), but on Windows if it's not found falls back to 117 // Windows PowerShell (`powershell`). 118 func findPowershell() string { 119 if runtime.GOOS != "windows" { 120 return "pwsh" 121 } 122 123 _, err := exec.LookPath("pwsh") 124 if err != nil { 125 return "powershell" 126 } 127 return "pwsh" 128 } 129 130 func (p *plugin) run(args ...interface{}) (interface{}, error) { 131 a := conv.ToStrings(args...) 132 a = append(p.args, a...) 133 134 name, a := p.buildCommand(a) 135 136 ctx, cancel := context.WithTimeout(p.ctx, p.timeout) 137 defer cancel() 138 139 var stdin *bytes.Buffer 140 if p.pipe && len(a) > 0 { 141 stdin = bytes.NewBufferString(a[len(a)-1]) 142 a = a[:len(a)-1] 143 } 144 145 c := exec.CommandContext(ctx, name, a...) 146 if stdin != nil { 147 c.Stdin = stdin 148 } 149 150 c.Stderr = p.stderr 151 outBuf := &bytes.Buffer{} 152 c.Stdout = outBuf 153 154 start := time.Now() 155 err := c.Start() 156 if err != nil { 157 return nil, fmt.Errorf("starting command: %w", err) 158 } 159 160 // make sure all signals are propagated 161 sigs := make(chan os.Signal, 1) 162 signal.Notify(sigs) 163 go func() { 164 select { 165 case sig := <-sigs: 166 // Pass signals to the sub-process 167 if c.Process != nil { 168 _ = c.Process.Signal(sig) 169 } 170 case <-ctx.Done(): 171 } 172 }() 173 174 err = c.Wait() 175 elapsed := time.Since(start) 176 177 if ctx.Err() != nil { 178 err = fmt.Errorf("plugin timed out after %v: %w", elapsed, ctx.Err()) 179 } 180 181 return outBuf.String(), err 182 }