get.porter.sh/porter@v1.3.0/pkg/plugins/pluggable/connection.go (about)

     1  package pluggable
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"context"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"os"
    12  	"os/exec"
    13  	"strconv"
    14  	"strings"
    15  	"sync"
    16  	"time"
    17  
    18  	"get.porter.sh/porter/pkg/config"
    19  	"get.porter.sh/porter/pkg/plugins"
    20  	"get.porter.sh/porter/pkg/tracing"
    21  	"github.com/hashicorp/go-hclog"
    22  	"github.com/hashicorp/go-multierror"
    23  	"github.com/hashicorp/go-plugin"
    24  	"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
    25  	"go.opentelemetry.io/otel/attribute"
    26  	"google.golang.org/grpc"
    27  )
    28  
    29  // PluginConnection represents a connection to a plugin.
    30  // It wraps the hashicorp/go-plugin library.
    31  type PluginConnection struct {
    32  	// config is the porter configuration
    33  	config *config.Config
    34  
    35  	// key is the fully-qualified plugin key.
    36  	// For example, porter.storage.mongodb
    37  	key plugins.PluginKey
    38  
    39  	// pluginType is the type of plugin we want to connect to.
    40  	pluginType PluginTypeConfig
    41  
    42  	// client is the plugin framework client used to manage the connection to the
    43  	// plugin.
    44  	client *plugin.Client
    45  
    46  	// pluginCmd is command that manages the plugin process
    47  	pluginCmd *exec.Cmd
    48  
    49  	// pluginProtocol is a connection that supports the plugin protocol, such as
    50  	// plugins.SecretsProtocol or plugins.StorageProtocol
    51  	pluginProtocol interface{}
    52  
    53  	// debugger is the optionally attached go debugger command.
    54  	debugger *exec.Cmd
    55  
    56  	// cancelLogCtx is the cancellation function for our go-routine that collects the plugin logs
    57  	cancelLogCtx context.CancelFunc
    58  
    59  	// logsWaitGroup is used to ensure that any go routines spawned by the plugin connection
    60  	// complete when Close is called. Otherwise we can get into a race between us and when the logger is closed.
    61  	logsWaitGroup sync.WaitGroup
    62  
    63  	// logsWriter receives logs from the plugin's stdout.
    64  	logsWriter *io.PipeWriter
    65  
    66  	// logsReader reads the logs from the plugin.
    67  	logsReader *io.PipeReader
    68  }
    69  
    70  func NewPluginConnection(c *config.Config, pluginType PluginTypeConfig, pluginKey plugins.PluginKey) *PluginConnection {
    71  	return &PluginConnection{
    72  		config:     c,
    73  		pluginType: pluginType,
    74  		key:        pluginKey,
    75  	}
    76  }
    77  
    78  // String returns the plugin key name
    79  func (c *PluginConnection) String() string {
    80  	return c.key.String()
    81  }
    82  
    83  // Start establishes a connection to the plugin.
    84  // * pluginCfg is the resolved plugin configuration section from the Porter config file
    85  func (c *PluginConnection) Start(ctx context.Context, pluginCfg io.Reader) error {
    86  	ctx, span := tracing.StartSpan(ctx,
    87  		attribute.String("plugin-key", c.key.String()))
    88  	defer span.EndSpan()
    89  
    90  	// Create a command to run the plugin
    91  	if c.key.IsInternal {
    92  		porterPath, err := c.config.GetPorterPath(ctx)
    93  		if err != nil {
    94  			return fmt.Errorf("could not determine the path to the porter pluginProtocol: %w", err)
    95  		}
    96  
    97  		c.pluginCmd = c.config.NewCommand(ctx, porterPath, "plugin", "run", c.key.String())
    98  	} else {
    99  		pluginPath, err := c.config.GetPluginPath(c.key.Binary)
   100  		if err != nil {
   101  			return span.Error(err)
   102  		}
   103  		span.SetAttributes(attribute.String("plugin-path", pluginPath))
   104  
   105  		c.pluginCmd = c.config.NewCommand(ctx, pluginPath, "run", c.key.String())
   106  	}
   107  	span.SetAttributes(attribute.String("plugin-path", c.pluginCmd.Path))
   108  
   109  	// Configure the command
   110  	c.pluginCmd.Stdin = pluginCfg
   111  	// The plugin doesn't read the config file, we pass in relevant plugin config to them directly
   112  	// The remaining relevant config (e.g. logging, tracing) is set via env vars
   113  	// Config files require using the plugins to resolve templated values, so we resolve once in Porter
   114  	// and pass relevant resolved values to the plugins explicitly
   115  	pluginConfigVars := c.config.ExportRemoteConfigAsEnvironmentVariables()
   116  	c.pluginCmd.Env = append(c.pluginCmd.Env, pluginConfigVars...)
   117  
   118  	// Pipe logs from the plugin and capture them
   119  	c.setupLogCollector(ctx)
   120  
   121  	var errbuf bytes.Buffer
   122  	logger := hclog.New(&hclog.LoggerOptions{
   123  		Name:       "porter",
   124  		Output:     c.logsWriter,
   125  		Level:      hclog.Debug,
   126  		JSONFormat: true,
   127  	})
   128  	c.client = plugin.NewClient(&plugin.ClientConfig{
   129  		HandshakeConfig: plugin.HandshakeConfig{
   130  			ProtocolVersion:  c.pluginType.ProtocolVersion,
   131  			MagicCookieKey:   plugins.HandshakeConfig.MagicCookieKey,
   132  			MagicCookieValue: plugins.HandshakeConfig.MagicCookieValue,
   133  		},
   134  		AllowedProtocols: []plugin.Protocol{
   135  			// All v1 plugins use gRPC
   136  			plugin.ProtocolGRPC,
   137  			// Enable net/rpc so that we can talk to older plugins from before v1
   138  			plugin.ProtocolNetRPC,
   139  		},
   140  		// Specify which plugin we want to connect to
   141  		Plugins: map[string]plugin.Plugin{
   142  			c.pluginType.Interface: c.pluginType.Plugin,
   143  		},
   144  		Cmd:          c.pluginCmd,
   145  		Logger:       logger,
   146  		Stderr:       &errbuf,
   147  		StartTimeout: getPluginStartTimeout(),
   148  		// Configure gRPC to propagate the span context so the plugin's traces
   149  		// show up under the current span
   150  		GRPCDialOptions: []grpc.DialOption{
   151  			grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
   152  		},
   153  	})
   154  
   155  	// Start the plugin
   156  	span.Debug("Connecting to plugin", attribute.String("plugin-command", strings.Join(c.pluginCmd.Args, " ")))
   157  	rpcClient, err := c.client.Client(ctx)
   158  	if err != nil {
   159  		pluginErr := errbuf.String()
   160  		if pluginErr != "" {
   161  			pluginErr = ": plugin stderr was " + pluginErr
   162  		}
   163  		err = fmt.Errorf("could not connect to the %s plugin%s: %w", c.key, pluginErr, err)
   164  		err = span.Error(err) // Emit the error before trying to close the connection
   165  		c.Close(ctx)
   166  		return err
   167  	}
   168  
   169  	err = c.setUpDebugger(ctx, c.client)
   170  	if err != nil {
   171  		err = span.Error(fmt.Errorf("could not set up debugger for plugin: %w", err))
   172  		c.Close(ctx) // Emit the error before trying to close the connection
   173  		return err
   174  	}
   175  
   176  	// Get a connection to the plugin
   177  	c.pluginProtocol, err = rpcClient.Dispense(c.key.Interface)
   178  	if err != nil {
   179  		err = span.Error(fmt.Errorf("could not connect to the %s plugin: %w", c.key, err))
   180  		c.Close(ctx) // Emit the error before trying to close the connection
   181  		return err
   182  	}
   183  
   184  	span.SetAttributes(attribute.Int("negotiated-protocol-version", c.client.NegotiatedVersion()))
   185  
   186  	return nil
   187  }
   188  
   189  // GetClient returns the raw connection to the pluginProtocol.
   190  // This value should be cast to the plugin protocol interface,
   191  // such as plugins.StorageProtocol or plugins.SecretsProtocol.
   192  func (c *PluginConnection) GetClient() interface{} {
   193  	return c.pluginProtocol
   194  }
   195  
   196  // Close releases the resources held by the plugin connection. Blocks until the
   197  // plugin process closes. Pass a context to control the graceful shutdown of the
   198  // plugin.
   199  func (c *PluginConnection) Close(ctx context.Context) error {
   200  	ctx, span := tracing.StartSpan(ctx,
   201  		attribute.String("plugin-key", c.key.String()))
   202  	defer span.EndSpan()
   203  
   204  	var bigErr *multierror.Error
   205  	if c.client != nil {
   206  		ctx, cancel := context.WithTimeout(ctx, getPluginStopTimeout())
   207  		defer cancel()
   208  
   209  		// Stop the plugin process
   210  		done := make(chan bool)
   211  		go func() {
   212  			// beware, this can block or deadlock
   213  			c.client.Kill(ctx)
   214  			done <- true
   215  		}()
   216  		select {
   217  		case <-done:
   218  			// plugin stopped as requested
   219  			break
   220  		case <-ctx.Done():
   221  			// Stop being nice, cleanup the plugin process without any waiting or blocking
   222  			span.Debugf("killing the plugin process: %s", ctx.Err())
   223  			c.client.HardKill()
   224  		}
   225  
   226  		// Stop processing logs from the plugin and wait for the log collection routine to complete
   227  		// This avoids a race where the log collector picks up a message but doesn't print it until
   228  		// after we close the logfile. This ensures that everything related to the plugins is released
   229  		// when Close exits.
   230  		c.cancelLogCtx()
   231  		c.logsWriter.Close()
   232  		c.logsReader.Close()
   233  		c.logsWaitGroup.Wait()
   234  
   235  		c.client = nil
   236  	}
   237  
   238  	if c.debugger != nil {
   239  		if c.debugger.Process != nil {
   240  			err := c.debugger.Process.Kill()
   241  			bigErr = multierror.Append(bigErr, err)
   242  		}
   243  		c.debugger = nil
   244  	}
   245  
   246  	return bigErr.ErrorOrNil()
   247  }
   248  
   249  func getPluginStartTimeout() time.Duration {
   250  	timeoutS := os.Getenv(PluginStartTimeoutEnvVar)
   251  	if timeoutD, err := time.ParseDuration(timeoutS); err == nil {
   252  		return timeoutD
   253  	}
   254  	return PluginStartTimeoutDefault
   255  }
   256  
   257  func getPluginStopTimeout() time.Duration {
   258  	timeoutS := os.Getenv(PluginStopTimeoutEnvVar)
   259  	if timeoutD, err := time.ParseDuration(timeoutS); err == nil {
   260  		return timeoutD
   261  	}
   262  	return PluginStopTimeoutDefault
   263  }
   264  
   265  func (c *PluginConnection) setUpDebugger(ctx context.Context, client *plugin.Client) error {
   266  	log := tracing.LoggerFromContext(ctx)
   267  
   268  	debugContext := c.config.PlugInDebugContext
   269  	if !(len(debugContext.RunPlugInInDebugger) > 0 && strings.ToLower(c.key.String()) == strings.TrimSpace(strings.ToLower(debugContext.RunPlugInInDebugger))) {
   270  		return nil
   271  	}
   272  
   273  	if !isDelveInstalled() {
   274  		return log.Error(errors.New("delve needs to be installed to debug plugins"))
   275  	}
   276  
   277  	listen := fmt.Sprintf("--listen=127.0.0.1:%s", debugContext.DebuggerPort)
   278  	if len(debugContext.PlugInWorkingDirectory) == 0 {
   279  		return log.Error(errors.New("plugin Working Directory is required for debugging"))
   280  	}
   281  	wd := fmt.Sprintf("--wd=%s", debugContext.PlugInWorkingDirectory)
   282  	pid := client.ReattachConfig().Pid
   283  	c.debugger = exec.CommandContext(ctx, "dlv", "attach", strconv.Itoa(pid), "--headless=true", "--api-version=2", "--log", listen, "--accept-multiclient", wd)
   284  	c.debugger.Stderr = os.Stderr
   285  	c.debugger.Stdout = os.Stdout
   286  	err := c.debugger.Start()
   287  	if err != nil {
   288  		return log.Error(fmt.Errorf("error starting dlv: %w", err))
   289  	}
   290  	return nil
   291  }
   292  
   293  // setupLogCollector kicks off a go routine to collect the plugin logs.
   294  func (c *PluginConnection) setupLogCollector(ctx context.Context) {
   295  	c.logsReader, c.logsWriter = io.Pipe()
   296  	ctx, c.cancelLogCtx = context.WithCancel(ctx)
   297  
   298  	c.logsWaitGroup.Add(1)
   299  	go c.collectPluginLogs(ctx)
   300  }
   301  
   302  // Watch the pipe between porter and the plugin for messages, and log them in a span.
   303  // We don't have a good way to associate them with the actual action in porter that triggered the plugin response
   304  // The best way to get that information is to instrument the plugin itself. This is mainly a fallback mechanism to
   305  // collect logs from an uninstrumented plugin.
   306  func (c *PluginConnection) collectPluginLogs(ctx context.Context) {
   307  	defer c.logsWaitGroup.Done()
   308  
   309  	ctx, span := tracing.StartSpan(ctx, attribute.String("plugin-key", c.key.String()))
   310  	defer span.EndSpan()
   311  
   312  	r := bufio.NewReader(c.logsReader)
   313  	for {
   314  		select {
   315  		case <-ctx.Done():
   316  			return
   317  		default:
   318  			line, err := r.ReadString('\n')
   319  			if err != nil {
   320  				if err == io.EOF {
   321  					return
   322  				}
   323  				return
   324  			}
   325  			if line == "" {
   326  				return
   327  			}
   328  
   329  			var pluginLog map[string]interface{}
   330  			err = json.Unmarshal([]byte(line), &pluginLog)
   331  			if err != nil {
   332  				continue
   333  			}
   334  
   335  			msg, ok := pluginLog["@message"].(string)
   336  			if !ok {
   337  				continue
   338  			}
   339  
   340  			switch pluginLog["@level"] {
   341  			case "error":
   342  				_ = span.Error(fmt.Errorf("%s", msg))
   343  			case "warn":
   344  				span.Warn(msg)
   345  			case "info":
   346  				// This message is always printed when a plugin exists
   347  				// polluting the output. This is hardcoded in hashicorp/go-plugin.
   348  				// Always convert it to a debug log.
   349  				if msg == "plugin process exited" {
   350  					span.Debug(msg) // Log at debug level instead of info
   351  				} else {
   352  					span.Info(msg)
   353  				}
   354  			default:
   355  				span.Debug(msg)
   356  			}
   357  		}
   358  	}
   359  }