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  }