intake/core/execute.go

203 lines
4.5 KiB
Go

package core
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"time"
)
func readPipe(
f io.ReadCloser,
send chan []byte,
done chan int,
) {
defer func() {
done <- 0
}()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
data := scanner.Bytes()
send <- data
}
}
func Execute(
source string,
argv []string,
env []string,
state []byte,
input string,
timeout time.Duration,
postProcess func(item Item) Item,
) (
items []Item,
newState []byte,
err error,
) {
log.Printf("executing %v", argv)
if len(argv) == 0 {
return nil, nil, errors.New("empty argv")
}
if source == "" {
return nil, nil, errors.New("empty source")
}
stateFile, err := os.CreateTemp("", "intake_state_*")
if err != nil {
return nil, nil, fmt.Errorf("error: failed to create temp state file: %v", err)
}
defer func() {
if err := os.Remove(stateFile.Name()); err != nil {
log.Printf("error: failed to delete %s", stateFile.Name())
}
}()
_, err = stateFile.Write(state)
if err != nil {
return nil, nil, fmt.Errorf("error: failed to write state file: %v", err)
}
env = append(env, "STATE_PATH="+stateFile.Name())
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Env = append(os.Environ(), env...)
cmd.WaitDelay = time.Second * 5
// Open pipes to the command
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, nil, err
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, nil, err
}
cout := make(chan []byte)
cerr := make(chan []byte)
coutDone := make(chan int)
cerrDone := make(chan int)
// Routines handling the process i/o
go readPipe(stdout, cout, coutDone)
go readPipe(stderr, cerr, cerrDone)
// Kick off the command
err = cmd.Start()
if err != nil {
return nil, nil, err
}
// Write any input to stdin and close it
io.WriteString(stdin, input)
stdin.Close()
// Collect outputs until std{out,err} close
parseError := false
stdoutDone := false
stderrDone := false
monitor:
for {
select {
case data := <-cout:
var item Item
err := json.Unmarshal(data, &item)
if err != nil || item.Id == "" {
log.Printf("[%s: stdout] %s", source, strings.TrimSpace(string(data)))
parseError = true
} else {
if postProcess != nil {
item = postProcess(item)
}
item.Active = true // These fields aren't up to
item.Created = 0 // the action to set and
item.Source = source // shouldn't be overrideable
log.Printf("[%s: item] %s\n", source, item.Id)
items = append(items, item)
}
case data := <-cerr:
log.Printf("[%s: stderr] %s\n", source, data)
case <-coutDone:
stdoutDone = true
if stdoutDone && stderrDone {
break monitor
}
case <-cerrDone:
stderrDone = true
if stdoutDone && stderrDone {
break monitor
}
}
}
err = cmd.Wait()
if ctx.Err() == context.DeadlineExceeded {
log.Printf("Timed out after %v\n", timeout)
return nil, nil, err
} else if exiterr, ok := err.(*exec.ExitError); ok {
log.Printf("error: %s failed with exit code %d\n", argv[0], exiterr.ExitCode())
return nil, nil, err
} else if err != nil {
log.Printf("error: %s failed with error: %s\n", argv[0], err)
return nil, nil, err
}
if parseError {
log.Printf("error: could not parse item\n")
return nil, nil, errors.New("invalid JSON")
}
newState, err = os.ReadFile(stateFile.Name())
if err != nil {
return nil, nil, fmt.Errorf("error: failed to read state file: %v", err)
}
return
}
// Execute an action that takes an item as input and returns the item modified.
// This is basically just a wrapper over [Execute] that handles the input and backfilling.
func ExecuteItemAction(
item Item,
argv []string,
env []string,
state []byte,
timeout time.Duration,
postProcess func(item Item) Item,
) (Item, []byte, error) {
itemJson, err := json.Marshal(item)
if err != nil {
return Item{}, nil, fmt.Errorf("failed to serialize item: %v", err)
}
res, newState, err := Execute(item.Source, argv, env, state, string(itemJson), timeout, postProcess)
if err != nil {
return Item{}, nil, fmt.Errorf("failed to execute action for %s/%s: %v", item.Source, item.Id, err)
}
if len(res) != 1 {
return Item{}, nil, fmt.Errorf("expected action to produce exactly one item, got %d", len(res))
}
newItem := res[0]
BackfillItem(&newItem, &item)
return newItem, newState, nil
}