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 }