github.com/hairyhenderson/gomplate/v3@v3.11.7/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/v3/conv"
    17  	"github.com/hairyhenderson/gomplate/v3/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  		})
    41  	}
    42  
    43  	return nil
    44  }
    45  
    46  // PluginOpts are options for controlling plugin function execution
    47  type PluginOpts struct {
    48  	// Stderr can be set to redirect the plugin's stderr to a custom writer.
    49  	// Defaults to os.Stderr.
    50  	Stderr io.Writer
    51  
    52  	// Timeout is the maximum amount of time to wait for the plugin to complete.
    53  	// Defaults to 5 seconds.
    54  	Timeout time.Duration
    55  
    56  	// Pipe indicates whether the last argument should be piped to the plugin's
    57  	// stdin (true) or processed as a commandline argument (false)
    58  	Pipe bool
    59  }
    60  
    61  // PluginFunc creates a template function that runs an external process - either
    62  // a shell script or commandline executable.
    63  func PluginFunc(ctx context.Context, cmd string, opts PluginOpts) func(...interface{}) (interface{}, error) {
    64  	timeout := opts.Timeout
    65  	if timeout == 0 {
    66  		timeout = 5 * time.Second
    67  	}
    68  
    69  	stderr := opts.Stderr
    70  	if stderr == nil {
    71  		stderr = os.Stderr
    72  	}
    73  
    74  	plugin := &plugin{
    75  		ctx:     ctx,
    76  		path:    cmd,
    77  		timeout: timeout,
    78  		pipe:    opts.Pipe,
    79  		stderr:  stderr,
    80  	}
    81  
    82  	return plugin.run
    83  }
    84  
    85  // plugin represents a custom function that binds to an external process to be executed
    86  type plugin struct {
    87  	ctx     context.Context
    88  	stderr  io.Writer
    89  	path    string
    90  	timeout time.Duration
    91  	pipe    bool
    92  }
    93  
    94  // builds a command that's appropriate for running scripts
    95  func (p *plugin) buildCommand(a []string) (name string, args []string) {
    96  	switch filepath.Ext(p.path) {
    97  	case ".ps1":
    98  		a = append([]string{"-File", p.path}, a...)
    99  		return findPowershell(), a
   100  	case ".cmd", ".bat":
   101  		a = append([]string{"/c", p.path}, a...)
   102  		return "cmd.exe", a
   103  	default:
   104  		return p.path, a
   105  	}
   106  }
   107  
   108  // finds the appropriate powershell command for the platform - prefers
   109  // PowerShell Core (`pwsh`), but on Windows if it's not found falls back to
   110  // Windows PowerShell (`powershell`).
   111  func findPowershell() string {
   112  	if runtime.GOOS != "windows" {
   113  		return "pwsh"
   114  	}
   115  
   116  	_, err := exec.LookPath("pwsh")
   117  	if err != nil {
   118  		return "powershell"
   119  	}
   120  	return "pwsh"
   121  }
   122  
   123  func (p *plugin) run(args ...interface{}) (interface{}, error) {
   124  	a := conv.ToStrings(args...)
   125  
   126  	name, a := p.buildCommand(a)
   127  
   128  	ctx, cancel := context.WithTimeout(p.ctx, p.timeout)
   129  	defer cancel()
   130  
   131  	var stdin *bytes.Buffer
   132  	if p.pipe && len(a) > 0 {
   133  		stdin = bytes.NewBufferString(a[len(a)-1])
   134  		a = a[:len(a)-1]
   135  	}
   136  
   137  	c := exec.CommandContext(ctx, name, a...)
   138  	if stdin != nil {
   139  		c.Stdin = stdin
   140  	}
   141  
   142  	c.Stderr = p.stderr
   143  	outBuf := &bytes.Buffer{}
   144  	c.Stdout = outBuf
   145  
   146  	start := time.Now()
   147  	err := c.Start()
   148  	if err != nil {
   149  		return nil, err
   150  	}
   151  
   152  	// make sure all signals are propagated
   153  	sigs := make(chan os.Signal, 1)
   154  	signal.Notify(sigs)
   155  	go func() {
   156  		select {
   157  		case sig := <-sigs:
   158  			// Pass signals to the sub-process
   159  			if c.Process != nil {
   160  				_ = c.Process.Signal(sig)
   161  			}
   162  		case <-ctx.Done():
   163  		}
   164  	}()
   165  
   166  	err = c.Wait()
   167  	elapsed := time.Since(start)
   168  
   169  	if ctx.Err() != nil {
   170  		err = fmt.Errorf("plugin timed out after %v: %w", elapsed, ctx.Err())
   171  	}
   172  
   173  	return outBuf.String(), err
   174  }