Quellcode durchsuchen

feat: exectool early support

jamesread vor 3 Monaten
Ursprung
Commit
e3ffc5aacc

+ 6 - 0
service/internal/config/config.go

@@ -12,6 +12,7 @@ type Action struct {
 	Icon                   string           `koanf:"icon"`
 	Shell                  string           `koanf:"shell"`
 	Exec                   []string         `koanf:"exec"`
+	ExecTool               *ExecToolConfig  `koanf:"execTool"`
 	ShellAfterCompleted    string           `koanf:"shellAfterCompleted"`
 	Timeout                int              `koanf:"timeout"`
 	Acls                   []string         `koanf:"acls"`
@@ -233,6 +234,11 @@ type DashboardComponent struct {
 	Contents     []*DashboardComponent `koanf:"contents"`
 }
 
+type ExecToolConfig struct {
+	Name   string         `koanf:"name"`
+	Config map[string]any `koanf:"config"`
+}
+
 func DefaultConfig() *Config {
 	return DefaultConfigWithBasePort(1337)
 }

+ 23 - 0
service/internal/config/sanitize.go

@@ -147,6 +147,8 @@ func (action *Action) sanitize(cfg *Config) {
 	action.Icon = lookupHTMLIcon(action.Icon, cfg.DefaultIconForActions)
 	action.PopupOnStart = sanitizePopupOnStart(action.PopupOnStart, cfg)
 
+	sanitizeActionExecutionMode(action)
+
 	if action.MaxConcurrent < 1 {
 		action.MaxConcurrent = 1
 	}
@@ -156,6 +158,27 @@ func (action *Action) sanitize(cfg *Config) {
 	}
 }
 
+func sanitizeActionExecutionMode(action *Action) {
+	hasShell := action.Shell != ""
+	hasExec := len(action.Exec) > 0
+	hasExecTool := action.ExecTool != nil && action.ExecTool.Name != "" && action.ExecTool.Config != nil
+
+	if hasExecTool && (hasShell || hasExec) {
+		log.Warnf("Action %q has both execTool and shell/exec; using execTool only", action.Title)
+		action.Shell = ""
+		action.Exec = nil
+	}
+	if hasExec && hasShell {
+		log.Warnf("Action %q has both shell and exec; using exec only", action.Title)
+		action.Shell = ""
+	}
+
+	if action.ExecTool != nil && (action.ExecTool.Name == "" || action.ExecTool.Config == nil) {
+		log.Warnf("Action %q has execTool with missing name or config; clearing execTool", action.Title)
+		action.ExecTool = nil
+	}
+}
+
 func (cfg *Config) sanitizeAuthRequireGuestsToLogin() {
 	if cfg.AuthRequireGuestsToLogin {
 		log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false")

+ 21 - 0
service/internal/config/sanitize_test.go

@@ -72,3 +72,24 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 		assert.NotEmpty(t, found.ID, "Inline action should have a generated ID")
 	}
 }
+
+func TestSanitizeActionExecutionMode(t *testing.T) {
+	t.Run("execTool with shell clears shell and exec", func(t *testing.T) {
+		a := &Action{Title: "Mixed", Shell: "echo hi", ExecTool: &ExecToolConfig{Name: "k8s", Config: map[string]any{"image": "busybox"}}}
+		sanitizeActionExecutionMode(a)
+		assert.Empty(t, a.Shell)
+		assert.Nil(t, a.Exec)
+		assert.NotNil(t, a.ExecTool)
+	})
+	t.Run("shell and exec keeps exec only", func(t *testing.T) {
+		a := &Action{Title: "Both", Shell: "echo hi", Exec: []string{"echo", "hi"}}
+		sanitizeActionExecutionMode(a)
+		assert.Empty(t, a.Shell)
+		assert.NotEmpty(t, a.Exec)
+	})
+	t.Run("execTool with empty name cleared", func(t *testing.T) {
+		a := &Action{Title: "Bad", ExecTool: &ExecToolConfig{Name: "", Config: map[string]any{}}}
+		sanitizeActionExecutionMode(a)
+		assert.Nil(t, a.ExecTool)
+	})
+}

+ 169 - 4
service/internal/executor/executor.go

@@ -16,7 +16,9 @@ import (
 
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
+	"io"
 	"os"
 	"os/exec"
 	"path"
@@ -79,6 +81,9 @@ type ExecutionRequest struct {
 	finalParsedCommand string
 	execArgs           []string
 	useDirectExec      bool
+	useExecTool        bool
+	execToolName       string
+	execToolConfig     []byte
 	executor           *Executor
 }
 
@@ -110,6 +115,8 @@ type InternalLogEntry struct {
 	*/
 	ActionTitle string
 	ActionIcon  string
+
+	Attributes map[string]string
 }
 
 // .Binding can be nil, so we need to handle that.
@@ -668,9 +675,11 @@ func stepParseArgs(req *ExecutionRequest) bool {
 
 	if hasExec(req) {
 		return handleExecBranch(req)
-	} else {
-		return handleShellBranch(req)
 	}
+	if hasExecTool(req) {
+		return handleExecToolBranch(req)
+	}
+	return handleShellBranch(req)
 }
 
 func handleExecBranch(req *ExecutionRequest) bool {
@@ -720,6 +729,36 @@ func hasExec(req *ExecutionRequest) bool {
 	return len(req.Binding.Action.Exec) > 0
 }
 
+func hasExecTool(req *ExecutionRequest) bool {
+	return req.Binding.Action.ExecTool != nil
+}
+
+func handleExecToolBranch(req *ExecutionRequest) bool {
+	if err := validateArguments(req.Arguments, req.Binding.Action); err != nil {
+		return fail(req, err)
+	}
+
+	cfg := req.Binding.Action.ExecTool.Config
+	if cfg == nil {
+		return fail(req, fmt.Errorf("execTool config is nil"))
+	}
+
+	applied, err := tpl.ApplyTemplatesToExecToolConfig(cfg, req.Binding.Entity, req.Arguments)
+	if err != nil {
+		return fail(req, err)
+	}
+
+	configJSON, err := json.Marshal(applied)
+	if err != nil {
+		return fail(req, err)
+	}
+
+	req.useExecTool = true
+	req.execToolName = req.Binding.Action.ExecTool.Name
+	req.execToolConfig = configJSON
+	return true
+}
+
 func fail(req *ExecutionRequest, err error) bool {
 	req.logEntry.Output = err.Error()
 	log.Warn(err.Error())
@@ -819,6 +858,55 @@ func (ost *OutputStreamer) String() string {
 	return ost.output.String()
 }
 
+const metadataMaxFirstLineLen = 64 * 1024
+
+type MetadataStreamFilter struct {
+	w        io.Writer
+	logEntry *InternalLogEntry
+	buf      []byte
+	done     bool
+}
+
+func (m *MetadataStreamFilter) Write(p []byte) (n int, err error) {
+	if m.done {
+		return m.w.Write(p)
+	}
+	m.buf = append(m.buf, p...)
+	if len(m.buf) > metadataMaxFirstLineLen {
+		m.done = true
+		_, _ = m.w.Write(m.buf)
+		m.buf = nil
+		return len(p), nil
+	}
+	idx := bytes.IndexByte(m.buf, '\n')
+	if idx < 0 {
+		return len(p), nil
+	}
+	line := m.buf[:idx]
+	m.buf = m.buf[idx+1:]
+	m.done = true
+	if bytes.HasPrefix(line, []byte("OLIVETIN_METADATA ")) {
+		jsonPart := line[len("OLIVETIN_METADATA "):]
+		var attrs map[string]string
+		if json.Unmarshal(jsonPart, &attrs) == nil && attrs != nil {
+			if m.logEntry.Attributes == nil {
+				m.logEntry.Attributes = make(map[string]string)
+			}
+			for k, v := range attrs {
+				m.logEntry.Attributes[k] = v
+			}
+		}
+	} else {
+		_, _ = m.w.Write(line)
+		_, _ = m.w.Write([]byte{'\n'})
+	}
+	if len(m.buf) > 0 {
+		_, _ = m.w.Write(m.buf)
+		m.buf = nil
+	}
+	return len(p), nil
+}
+
 func buildEnv(args map[string]string) []string {
 	ret := append(os.Environ(), "OLIVETIN=1")
 
@@ -837,9 +925,12 @@ func buildEnv(args map[string]string) []string {
 }
 
 func stepExec(req *ExecutionRequest) bool {
-	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor)
+	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor, req.logEntry)
 	defer cancel()
 	streamer := &OutputStreamer{Req: req}
+	if req.useExecTool {
+		return stepExecTool(req, ctx, streamer)
+	}
 	cmd := buildCommand(ctx, req)
 	if cmd == nil {
 		req.logEntry.Output = "Cannot execute: no command arguments provided"
@@ -871,6 +962,80 @@ func stepExec(req *ExecutionRequest) bool {
 	return true
 }
 
+func stepExecTool(req *ExecutionRequest, ctx *timeoutContext, streamer *OutputStreamer) bool {
+	toolName := "olivetin-" + req.execToolName
+	if _, err := exec.LookPath(toolName); err != nil {
+		req.logEntry.Output = fmt.Sprintf("exec tool %s not found in PATH", toolName)
+		log.Warnf("exec tool %s not found in PATH", toolName)
+		return false
+	}
+	cmd := wrapCommandExecTool(ctx.Context, req.execToolName)
+	if cmd == nil {
+		return false
+	}
+	stdinPayload := buildExecToolStdinPayload(req)
+	filter := &MetadataStreamFilter{w: streamer, logEntry: req.logEntry}
+	cmd.Stdout = filter
+	cmd.Stderr = streamer
+	cmd.Env = buildExecToolEnv(req)
+	stdinPipe, err := cmd.StdinPipe()
+	if err != nil {
+		req.logEntry.Output = "Failed to create stdin pipe: " + err.Error()
+		return false
+	}
+	req.logEntry.ExecutionStarted = true
+	runerr := cmd.Start()
+	req.logEntry.Process = cmd.Process
+	ctx.setProcess(cmd.Process)
+	_, _ = stdinPipe.Write(stdinPayload)
+	_ = stdinPipe.Close()
+	waiterr := cmd.Wait()
+	req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
+	req.logEntry.Output = streamer.String()
+
+	appendErrorToStderr(runerr, req.logEntry)
+	appendErrorToStderr(waiterr, req.logEntry)
+
+	if ctx.Err() == context.DeadlineExceeded {
+		log.WithFields(log.Fields{
+			"actionTitle": req.logEntry.ActionTitle,
+		}).Warnf("Action timed out")
+		req.logEntry.TimedOut = true
+		req.logEntry.Output += "OliveTin::timeout - this action timed out after " + fmt.Sprintf("%v", req.Binding.Action.Timeout) + " seconds. If you need more time for this action, set a longer timeout. See https://docs.olivetin.app/action_customization/timeouts.html for more help."
+	}
+
+	req.logEntry.DatetimeFinished = time.Now()
+	return true
+}
+
+func buildExecToolStdinPayload(req *ExecutionRequest) []byte {
+	var configAny any
+	_ = json.Unmarshal(req.execToolConfig, &configAny)
+	payload := map[string]any{
+		"config":      configAny,
+		"arguments":   req.Arguments,
+		"timeout":     req.Binding.Action.Timeout,
+		"tracking_id": req.TrackingID,
+	}
+	data, _ := json.Marshal(payload)
+	return data
+}
+
+func buildExecToolEnv(req *ExecutionRequest) []string {
+	env := append(os.Environ(), "OLIVETIN=1")
+	env = append(env, "OLIVETIN_TRACKING_ID="+req.TrackingID)
+	env = append(env, "OLIVETIN_ACTION_TITLE="+req.logEntry.ActionTitle)
+	env = append(env, fmt.Sprintf("OLIVETIN_TIMEOUT=%d", req.Binding.Action.Timeout))
+	for k, v := range req.Arguments {
+		varName := fmt.Sprintf("%v", strings.TrimSpace(strings.ToUpper(k)))
+		if varName == "" {
+			continue
+		}
+		env = append(env, fmt.Sprintf("%v=%v", varName, v))
+	}
+	return env
+}
+
 func buildCommand(ctx context.Context, req *ExecutionRequest) *exec.Cmd {
 	if req.useDirectExec {
 		return wrapCommandDirect(ctx, req.execArgs)
@@ -890,7 +1055,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
 		return true
 	}
 
-	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor)
+	ctx, cancel := newTimeoutContext(context.Background(), time.Duration(req.Binding.Action.Timeout)*time.Second, req.executor, nil)
 	defer cancel()
 
 	var stdout bytes.Buffer

+ 23 - 2
service/internal/executor/executor_unix.go

@@ -10,8 +10,23 @@ import (
 )
 
 func (e *Executor) Kill(execReq *InternalLogEntry) error {
-	// A negative PID means to kill the whole process group. This is *nix specific behavior.
-	return syscall.Kill(-execReq.Process.Pid, syscall.SIGKILL)
+	if execReq == nil {
+		return nil
+	}
+	helper := ""
+	killID := ""
+	if execReq.Attributes != nil {
+		helper = execReq.Attributes["helper"]
+		killID = execReq.Attributes["kill_id"]
+	}
+	if helper != "" && killID != "" {
+		killCmd := exec.CommandContext(context.Background(), "olivetin-"+helper, "kill", killID)
+		_ = killCmd.Run()
+	}
+	if execReq.Process != nil {
+		return syscall.Kill(-execReq.Process.Pid, syscall.SIGKILL)
+	}
+	return nil
 }
 
 func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
@@ -35,3 +50,9 @@ func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
 
 	return cmd
 }
+
+func wrapCommandExecTool(ctx context.Context, name string) *exec.Cmd {
+	cmd := exec.CommandContext(ctx, "olivetin-"+name, "exec")
+	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+	return cmd
+}

+ 21 - 1
service/internal/executor/executor_windows.go

@@ -10,7 +10,23 @@ import (
 )
 
 func (e *Executor) Kill(execReq *InternalLogEntry) error {
-	return execReq.Process.Kill()
+	if execReq == nil {
+		return nil
+	}
+	helper := ""
+	killID := ""
+	if execReq.Attributes != nil {
+		helper = execReq.Attributes["helper"]
+		killID = execReq.Attributes["kill_id"]
+	}
+	if helper != "" && killID != "" {
+		killCmd := exec.CommandContext(context.Background(), "olivetin-"+helper, "kill", killID)
+		_ = killCmd.Run()
+	}
+	if execReq.Process != nil {
+		return execReq.Process.Kill()
+	}
+	return nil
 }
 
 func wrapCommandInShell(ctx context.Context, finalParsedCommand string) *exec.Cmd {
@@ -30,3 +46,7 @@ func wrapCommandDirect(ctx context.Context, execArgs []string) *exec.Cmd {
 
 	return exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
 }
+
+func wrapCommandExecTool(ctx context.Context, name string) *exec.Cmd {
+	return exec.CommandContext(ctx, "olivetin-"+name, "exec")
+}

+ 25 - 24
service/internal/executor/timeout_context.go

@@ -5,44 +5,43 @@ import (
 	"os"
 	"sync"
 	"time"
-
-	log "github.com/sirupsen/logrus"
 )
 
 // timeoutContext is a custom context that kills the process group when cancelled due to timeout.
 type timeoutContext struct {
 	context.Context
 	cancel    context.CancelFunc
+	logEntry  *InternalLogEntry
 	process   *os.Process
 	executor  *Executor
 	processMu sync.Mutex
 }
 
 // newTimeoutContext creates a context that will kill the process group when the timeout expires.
-func newTimeoutContext(parent context.Context, timeout time.Duration, executor *Executor) (*timeoutContext, context.CancelFunc) {
+// logEntry is the same InternalLogEntry as the running execution so Kill(logEntry) can use Attributes and Process.
+// Pass nil logEntry for subprocesses (e.g. shellAfterCompleted); then setProcess stores the process and it is killed on timeout.
+func newTimeoutContext(parent context.Context, timeout time.Duration, executor *Executor, logEntry *InternalLogEntry) (*timeoutContext, context.CancelFunc) {
 	ctx, cancel := context.WithTimeout(parent, timeout)
 	tc := &timeoutContext{
 		Context:  ctx,
 		cancel:   cancel,
+		logEntry: logEntry,
 		executor: executor,
 	}
 
-	// Start a goroutine that kills the process group when the context is cancelled
 	go func() {
 		<-ctx.Done()
-		if ctx.Err() == context.DeadlineExceeded {
-			tc.processMu.Lock()
-			process := tc.process
-			tc.processMu.Unlock()
-
-			if process != nil {
-				logEntry := &InternalLogEntry{Process: process}
-				if err := executor.Kill(logEntry); err != nil {
-					log.WithFields(log.Fields{
-						"error": err,
-					}).Warnf("Failed to kill process group on timeout")
-				}
-			}
+		if ctx.Err() != context.DeadlineExceeded {
+			return
+		}
+		tc.processMu.Lock()
+		entry := tc.logEntry
+		process := tc.process
+		tc.processMu.Unlock()
+		if entry != nil {
+			_ = executor.Kill(entry)
+		} else if process != nil {
+			_ = executor.Kill(&InternalLogEntry{Process: process})
 		}
 	}()
 
@@ -51,16 +50,18 @@ func newTimeoutContext(parent context.Context, timeout time.Duration, executor *
 
 func (tc *timeoutContext) setProcess(process *os.Process) {
 	tc.processMu.Lock()
-	tc.process = process
+	if tc.logEntry != nil {
+		tc.logEntry.Process = process
+	} else {
+		tc.process = process
+	}
 	tc.processMu.Unlock()
 
-	// If deadline already expired before process was set, kill now
 	if tc.Context.Err() == context.DeadlineExceeded && process != nil {
-		logEntry := &InternalLogEntry{Process: process}
-		if err := tc.executor.Kill(logEntry); err != nil {
-			log.WithFields(log.Fields{
-				"error": err,
-			}).Warnf("Failed to kill process group on timeout (late registration)")
+		if tc.logEntry != nil {
+			_ = tc.executor.Kill(tc.logEntry)
+		} else {
+			_ = tc.executor.Kill(&InternalLogEntry{Process: process})
 		}
 	}
 }

+ 57 - 0
service/internal/tpl/templates.go

@@ -200,6 +200,63 @@ func parseTemplate(source string, data any) (string, error) {
 	}
 }
 
+const maxExecToolConfigDepth = 64
+
+// ApplyTemplatesToExecToolConfig recursively applies action templates to all string values in config.
+// Depth-first over maps and slices; only string values are templated. Returns an error if any template fails.
+func ApplyTemplatesToExecToolConfig(config map[string]any, ent *entities.Entity, args map[string]string) (map[string]any, error) {
+	out, err := applyTemplatesToValue(config, ent, args, 0)
+	if err != nil {
+		return nil, err
+	}
+	if out == nil {
+		return nil, nil
+	}
+	return out.(map[string]any), nil
+}
+
+func applyTemplatesToValue(v any, ent *entities.Entity, args map[string]string, depth int) (any, error) {
+	if depth > maxExecToolConfigDepth {
+		return nil, fmt.Errorf("execTool config nested too deeply")
+	}
+	switch val := v.(type) {
+	case string:
+		return ParseTemplateWithActionContext(val, ent, args)
+	case map[string]any:
+		result := make(map[string]any, len(val))
+		for k, elem := range val {
+			transformed, err := applyTemplatesToValue(elem, ent, args, depth+1)
+			if err != nil {
+				return nil, err
+			}
+			result[k] = transformed
+		}
+		return result, nil
+	case []any:
+		result := make([]any, len(val))
+		for i, elem := range val {
+			transformed, err := applyTemplatesToValue(elem, ent, args, depth+1)
+			if err != nil {
+				return nil, err
+			}
+			result[i] = transformed
+		}
+		return result, nil
+	case []string:
+		result := make([]any, len(val))
+		for i, elem := range val {
+			transformed, err := applyTemplatesToValue(elem, ent, args, depth+1)
+			if err != nil {
+				return nil, err
+			}
+			result[i] = transformed
+		}
+		return result, nil
+	default:
+		return v, nil
+	}
+}
+
 func ParseTemplateOfActionBeforeExec(source string, ent *entities.Entity) string {
 	result, err := ParseTemplateWithActionContext(source, ent, nil)
 	if err != nil {