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 }