From 26a88546d3373de84dacb70c3a2648345a34a47d Mon Sep 17 00:00:00 2001 From: Joel Wetzell Date: Fri, 11 Sep 2026 09:01:38 -0500 Subject: [PATCH] add processor to execute commands on system --- internal/processor/os-exec.go | 124 +++++++++++++++++ internal/processor/test/os-exec_test.go | 170 ++++++++++++++++++++++++ 2 files changed, 294 insertions(+) create mode 100644 internal/processor/os-exec.go create mode 100644 internal/processor/test/os-exec_test.go diff --git a/internal/processor/os-exec.go b/internal/processor/os-exec.go new file mode 100644 index 0000000..fc35d27 --- /dev/null +++ b/internal/processor/os-exec.go @@ -0,0 +1,124 @@ +package processor + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "text/template" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/jwetzell/showbridge-go/config" + "github.com/jwetzell/showbridge-go/internal/common" +) + +func init() { + RegisterProcessor(ProcessorRegistration{ + Type: "os.exec", + Title: "Exec Command", + ParamsSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "command": { + Title: "Command", + Description: "the command to execute", + Type: "string", + }, + "args": { + Title: "Args", + Description: "the arguments for the command", + Type: "array", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Required: []string{"command"}, + }, + New: func(moduleConfig config.ProcessorConfig) (Processor, error) { + params := moduleConfig.Params + + commandString, err := params.GetString("command") + if err != nil { + if errors.Is(err, config.ErrParamNotFound) { + return nil, fmt.Errorf("os.exec command error: not found") + } else { + return nil, fmt.Errorf("os.exec command error: %w", err) + } + } + commandTemplate, err := template.New("command").Parse(commandString) + + if err != nil { + return nil, err + } + + argStrings, err := params.GetStringSlice("args") + if err != nil { + if errors.Is(err, config.ErrParamNotFound) { + argStrings = []string{} + } else { + return nil, fmt.Errorf("os.exec args error: %w", err) + } + } + + argTemplates := make([]*template.Template, len(argStrings)) + for i, argString := range argStrings { + argTemplate, err := template.New(fmt.Sprintf("arg-%d", i)).Parse(argString) + if err != nil { + return nil, err + } + argTemplates[i] = argTemplate + } + return &OsExec{config: moduleConfig, CommandTemplate: commandTemplate, Args: argTemplates}, nil + }, + }) +} + +type OsExec struct { + CommandTemplate *template.Template + config config.ProcessorConfig + Args []*template.Template +} + +func (oe *OsExec) Process(ctx context.Context, wrappedPayload common.WrappedPayload) (common.WrappedPayload, error) { + templateData := wrappedPayload + + var commandBuffer bytes.Buffer + err := oe.CommandTemplate.Execute(&commandBuffer, templateData) + + if err != nil { + wrappedPayload.End = true + return wrappedPayload, err + } + + commandString := commandBuffer.String() + + argStrings := make([]string, len(oe.Args)) + for i, argTemplate := range oe.Args { + var argBuffer bytes.Buffer + err := argTemplate.Execute(&argBuffer, templateData) + if err != nil { + wrappedPayload.End = true + return wrappedPayload, err + } + argStrings[i] = argBuffer.String() + } + + out, err := exec.Command(commandString, argStrings...).Output() + if err != nil { + wrappedPayload.End = true + return wrappedPayload, fmt.Errorf("os.exec command execution error: %w", err) + } + wrappedPayload.Payload = out + return wrappedPayload, nil +} + +func (oe *OsExec) Id() string { + return oe.config.Id +} + +func (oe *OsExec) Type() string { + return oe.config.Type +} diff --git a/internal/processor/test/os-exec_test.go b/internal/processor/test/os-exec_test.go new file mode 100644 index 0000000..b74d852 --- /dev/null +++ b/internal/processor/test/os-exec_test.go @@ -0,0 +1,170 @@ +package processor_test + +import ( + "reflect" + "testing" + + "github.com/jwetzell/showbridge-go/config" + "github.com/jwetzell/showbridge-go/internal/common" + "github.com/jwetzell/showbridge-go/internal/processor" + "github.com/jwetzell/showbridge-go/internal/test" +) + +func TestOsExecFromRegistry(t *testing.T) { + registration, ok := processor.GetProcessorRegistration("os.exec") + if !ok { + t.Fatalf("os.exec processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Id: "test-id", + Type: "os.exec", + Params: map[string]any{ + "command": "ls", + }, + }) + if err != nil { + t.Fatalf("failed to create os.exec processor: %s", err) + } + + if processorInstance.Id() != "test-id" { + t.Fatalf("os.exec processor has wrong id: %s", processorInstance.Id()) + } + + if processorInstance.Type() != "os.exec" { + t.Fatalf("os.exec processor has wrong type: %s", processorInstance.Type()) + } +} + +func TestGoodOsExec(t *testing.T) { + testCases := []struct { + name string + params map[string]any + payload any + expected []byte + }{} + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + registration, ok := processor.GetProcessorRegistration("os.exec") + if !ok { + t.Fatalf("os.exec processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Type: "os.exec", + Params: testCase.params, + }) + + if err != nil { + t.Fatalf("os.exec failed to create processor: %s", err) + } + + got, err := processorInstance.Process(t.Context(), common.WrappedPayload{Payload: testCase.payload}) + + if err != nil { + t.Fatalf("os.exec processing failed: %s", err) + } + + if !reflect.DeepEqual(got.Payload, testCase.expected) { + t.Fatalf("os.exec got payload '%v', expected '%v'", got.Payload, testCase.expected) + } + }) + } +} + +func TestBadOsExec(t *testing.T) { + tests := []struct { + name string + params map[string]any + payload any + errorString string + }{ + { + name: "no command parameter", + params: map[string]any{}, + payload: test.TestStruct{}, + errorString: "os.exec command error: not found", + }, + { + name: "non-string command parameter", + params: map[string]any{ + "command": 12345, + }, + payload: test.TestStruct{}, + errorString: "os.exec command error: not a string", + }, + { + name: "command template syntax error", + params: map[string]any{ + "command": "{{", + }, + payload: test.TestStruct{}, + errorString: "template: command:1: unclosed action", + }, + { + name: "command templating error", + params: map[string]any{ + "command": "{{.NonExistentField}}", + }, + payload: test.TestStruct{}, + errorString: "template: command:1:2: executing \"command\" at <.NonExistentField>: can't evaluate field NonExistentField in type common.WrappedPayload", + }, + { + name: "non-string in args", + params: map[string]any{ + "command": "echo", + "args": []any{12345}, + }, + payload: test.TestStruct{}, + errorString: "os.exec args error: not a string slice", + }, + { + name: "args template syntax error", + params: map[string]any{ + "command": "echo", + "args": []any{"{{"}, + }, + payload: test.TestStruct{}, + errorString: "template: arg-0:1: unclosed action", + }, + { + name: "args templating error", + params: map[string]any{ + "command": "echo", + "args": []any{"{{.Unknown}}"}, + }, + payload: test.TestStruct{}, + errorString: "template: arg-0:1:2: executing \"arg-0\" at <.Unknown>: can't evaluate field Unknown in type common.WrappedPayload", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registration, ok := processor.GetProcessorRegistration("os.exec") + if !ok { + t.Fatalf("os.exec processor not registered") + } + + processorInstance, err := registration.New(config.ProcessorConfig{ + Type: "os.exec", + Params: test.params, + }) + if err != nil { + if err.Error() != test.errorString { + t.Fatalf("os.exec got error '%s', expected '%s'", err.Error(), test.errorString) + } + return + } + got, err := processorInstance.Process(t.Context(), common.WrappedPayload{Payload: test.payload}) + + if err == nil { + t.Fatalf("os.exec expected to fail but succeeded, got: %v", got) + + } + if err.Error() != test.errorString { + t.Fatalf("os.exec got error '%s', expected '%s'", err.Error(), test.errorString) + } + }) + } +}