github.com/choria-io/go-choria@v0.28.1-0.20240416190746-b3bf9c7d5a45/providers/discovery/external/external.go (about)

     1  // Copyright (c) 2021-2022, R.I. Pienaar and the Choria Project contributors
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package external
     6  
     7  import (
     8  	"bufio"
     9  	"context"
    10  	"encoding/json"
    11  	"fmt"
    12  	"io"
    13  	"os"
    14  	"os/exec"
    15  	"path/filepath"
    16  	"sync"
    17  	"time"
    18  
    19  	"github.com/choria-io/go-choria/inter"
    20  	"github.com/google/shlex"
    21  
    22  	"github.com/choria-io/go-choria/protocol"
    23  
    24  	"github.com/sirupsen/logrus"
    25  )
    26  
    27  // External implements discovery via externally executed binaries
    28  type External struct {
    29  	fw      inter.Framework
    30  	timeout time.Duration
    31  	log     *logrus.Entry
    32  }
    33  
    34  // Response is the expected response from the external script on its STDOUT
    35  type Response struct {
    36  	Protocol string   `json:"protocol"`
    37  	Nodes    []string `json:"nodes"`
    38  	Error    string   `json:"error"`
    39  }
    40  
    41  // Request is the request sent to the external script on its STDIN
    42  type Request struct {
    43  	Protocol   string            `json:"protocol"`
    44  	Collective string            `json:"collective"`
    45  	Filter     *protocol.Filter  `json:"filter"`
    46  	Options    map[string]string `json:"options"`
    47  	Schema     string            `json:"$schema"`
    48  	Timeout    float64           `json:"timeout"`
    49  }
    50  
    51  const (
    52  	// ResponseProtocol is the protocol responses from the external script should have
    53  	ResponseProtocol = "io.choria.choria.discovery.v1.external_reply"
    54  	// RequestProtocol is a protocol set in the request that the external script can validate
    55  	RequestProtocol = "io.choria.choria.discovery.v1.external_request"
    56  	// RequestSchema is the location to a JSON Schema for requests
    57  	RequestSchema = "https://choria.io/schemas/choria/discovery/v1/external_request.json"
    58  )
    59  
    60  func New(fw inter.Framework) *External {
    61  	return &External{
    62  		fw:      fw,
    63  		timeout: time.Second * time.Duration(fw.Configuration().DiscoveryTimeout),
    64  		log:     fw.Logger("external_discovery"),
    65  	}
    66  }
    67  
    68  func (e *External) Discover(ctx context.Context, opts ...DiscoverOption) (n []string, err error) {
    69  	dopts := &dOpts{
    70  		collective: e.fw.Configuration().MainCollective,
    71  		timeout:    e.timeout,
    72  		command:    e.fw.Configuration().Choria.ExternalDiscoveryCommand,
    73  		do:         make(map[string]string),
    74  	}
    75  
    76  	for _, opt := range opts {
    77  		opt(dopts)
    78  	}
    79  
    80  	if dopts.filter == nil {
    81  		dopts.filter = protocol.NewFilter()
    82  	}
    83  
    84  	if dopts.timeout < time.Second {
    85  		e.log.Warnf("Forcing discovery timeout to minimum 1 second")
    86  		dopts.timeout = time.Second
    87  	}
    88  
    89  	command, ok := dopts.do["command"]
    90  	if ok && command != "" {
    91  		dopts.command = command
    92  		delete(dopts.do, "command")
    93  	}
    94  
    95  	if dopts.command == "" {
    96  		return nil, fmt.Errorf("no command specified for external discovery")
    97  	}
    98  
    99  	timeoutCtx, cancel := context.WithTimeout(ctx, dopts.timeout)
   100  	defer cancel()
   101  
   102  	idat := &Request{
   103  		Schema:     RequestSchema,
   104  		Protocol:   RequestProtocol,
   105  		Timeout:    dopts.timeout.Seconds(),
   106  		Collective: dopts.collective,
   107  		Filter:     dopts.filter,
   108  		Options:    dopts.do,
   109  	}
   110  
   111  	req, err := json.Marshal(idat)
   112  	if err != nil {
   113  		return nil, fmt.Errorf("could not encode the filter: %s", err)
   114  	}
   115  
   116  	reqfile, err := os.CreateTemp("", "request")
   117  	if err != nil {
   118  		return nil, fmt.Errorf("could not create request temp file: %s", err)
   119  	}
   120  	defer os.Remove(reqfile.Name())
   121  
   122  	repfile, err := os.CreateTemp("", "reply")
   123  	if err != nil {
   124  		return nil, fmt.Errorf("could not create reply temp file: %s", err)
   125  	}
   126  	defer os.Remove(repfile.Name())
   127  	repfile.Close()
   128  
   129  	_, err = reqfile.Write(req)
   130  	if err != nil {
   131  		return nil, fmt.Errorf("could not create reply temp file: %s", err)
   132  	}
   133  
   134  	var args []string
   135  	parts, err := shlex.Split(dopts.command)
   136  	if err != nil {
   137  		return nil, err
   138  	}
   139  	args = append(args, parts[0])
   140  	if len(parts) > 1 {
   141  		args = append(args, parts[1:]...)
   142  	}
   143  	args = append(args, reqfile.Name(), repfile.Name(), RequestProtocol)
   144  	command = args[0]
   145  
   146  	cmd := exec.CommandContext(timeoutCtx, command, args[1:]...)
   147  	cmd.Dir = os.TempDir()
   148  	cmd.Env = []string{
   149  		"CHORIA_EXTERNAL_REQUEST=" + reqfile.Name(),
   150  		"CHORIA_EXTERNAL_REPLY=" + repfile.Name(),
   151  		"CHORIA_EXTERNAL_PROTOCOL=" + RequestProtocol,
   152  		"PATH=" + os.Getenv("PATH"),
   153  	}
   154  
   155  	stdout, err := cmd.StdoutPipe()
   156  	if err != nil {
   157  		return nil, fmt.Errorf("could not open STDOUT: %s", err)
   158  	}
   159  
   160  	stderr, err := cmd.StderrPipe()
   161  	if err != nil {
   162  		return nil, fmt.Errorf("could not open STDERR: %s", err)
   163  	}
   164  
   165  	wg := &sync.WaitGroup{}
   166  	outputReader := func(wg *sync.WaitGroup, in io.ReadCloser, logger func(args ...any)) {
   167  		defer wg.Done()
   168  
   169  		scanner := bufio.NewScanner(in)
   170  		for scanner.Scan() {
   171  			logger(scanner.Text())
   172  		}
   173  	}
   174  
   175  	wg.Add(1)
   176  	go outputReader(wg, stderr, e.log.Error)
   177  	wg.Add(1)
   178  	go outputReader(wg, stdout, e.log.Info)
   179  
   180  	err = cmd.Start()
   181  	if err != nil {
   182  		return nil, fmt.Errorf("executing %s failed: %s", filepath.Base(command), err)
   183  	}
   184  
   185  	cmd.Wait()
   186  	wg.Wait()
   187  
   188  	if cmd.ProcessState.ExitCode() != 0 {
   189  		return nil, fmt.Errorf("executing %s failed: exit status %d", filepath.Base(command), cmd.ProcessState.ExitCode())
   190  	}
   191  
   192  	repjson, err := os.ReadFile(repfile.Name())
   193  	if err != nil {
   194  		return nil, fmt.Errorf("failed to read reply json: %s", err)
   195  	}
   196  
   197  	var resp Response
   198  	err = json.Unmarshal(repjson, &resp)
   199  	if err != nil {
   200  		return nil, fmt.Errorf("failed to decode reply json: %s", err)
   201  	}
   202  
   203  	if resp.Error != "" {
   204  		return nil, fmt.Errorf(resp.Error)
   205  	}
   206  
   207  	if resp.Protocol != ResponseProtocol {
   208  		return nil, fmt.Errorf("invalid response received, expected protocol %q got %q", ResponseProtocol, resp.Protocol)
   209  	}
   210  
   211  	return resp.Nodes, nil
   212  }