Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ urlCommands:
logs
-f
my-container
outputType: stream
executionMode: stream
```

Call the endpoint:
Expand Down Expand Up @@ -200,7 +200,7 @@ urlCommands:
- url: POST /maintenance/backup
commandTemplate: |
/usr/local/bin/backup.sh
outputType: none
executionMode: async
timeout: 1h
```

Expand Down Expand Up @@ -332,11 +332,11 @@ Each entry contains:
* `graceTerminationTimeout` *(optional)*
The time to wait for the process to exit gracefully after sending `SIGTERM` when the context is cancelled (e.g., client disconnects or timeout occurs) before sending `SIGKILL`. Format: [Go Duration](https://pkg.go.dev/time#ParseDuration). Default: no grace period (sends `SIGKILL` immediately).

* `outputType` *(optional)*
Determines how the command output is returned:
- `text`: (default) returns the full output once the command finishes.
- `stream`: returns the output in real-time as it is produced by the command.
- `none`: executes the command asynchronously in the background. The HTTP response is sent immediately after the process starts, and any output is discarded. Note that the optional `timeout` is still respected for background processes.
* `executionMode` *(optional)*
Determines how the command is executed and how its output is returned:
- `buffered`: (default) run command synchronously and return the full output once it finishes.
- `stream`: run command synchronously and stream output in real-time as it is produced.
- `async`: start command and return immediately without waiting for it to finish. Any output is discarded. Note that the optional `timeout` is still respected for background processes.

* `callGate` *(optional)*
Controls the concurrency of command execution. It allows you to limit how many instances of a command (or a group of commands) can run simultaneously.
Expand Down
6 changes: 3 additions & 3 deletions config.sample-ssl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,22 +40,22 @@ urlCommands:
bash
-c
for i in {1..10}; do echo -n "$i "; date '+%H:%M:%S'; sleep 1; done
outputType: stream
executionMode: stream
timeout: 30s
- url: GET /stream/time-with-timeout-5
commandTemplate: |
bash
-c
for i in {1..10}; do echo -n "$i "; date '+%H:%M:%S'; sleep 1; done
outputType: stream
executionMode: stream
timeout: 5s
- url: GET /cmd/sleep
callGate:
mode: single
commandTemplate: |
/bin/sleep
5
outputType: none
executionMode: async
timeout: 10s
- url: POST /maintenance/cleanup
callGate:
Expand Down
6 changes: 3 additions & 3 deletions config.sample.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ urlCommands:
bash
-c
for i in {1..10}; do echo -n "$i "; date '+%H:%M:%S'; sleep 1; done
outputType: stream
executionMode: stream
timeout: 30s
- url: GET /stream/time-with-timeout-5
commandTemplate: |
bash
-c
for i in {1..10}; do echo -n "$i "; date '+%H:%M:%S'; sleep 1; done
outputType: stream
executionMode: stream
timeout: 5s
- url: GET /cmd/sleep
callGate:
mode: single
commandTemplate: |
/bin/sleep
5
outputType: none
executionMode: async
timeout: 10s
- url: POST /maintenance/cleanup
callGate:
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ type CommandConfig struct {
Params ParamsConfig `yaml:"params"`
Timeout *time.Duration `yaml:"timeout"`
GraceTerminationTimeout *time.Duration `yaml:"graceTerminationTimeout"`
OutputType string `yaml:"outputType"`
ExecutionMode string `yaml:"executionMode"`
CallGate *CallGateConfig `yaml:"callGate"`
}

Expand Down
60 changes: 58 additions & 2 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ func TestLoadConfig(t *testing.T) {
Params: config.ParamsConfig{
BodyAsJSON: ptrBool(true),
},
Timeout: ptrDuration(5 * time.Second),
OutputType: "text",
Timeout: ptrDuration(5 * time.Second),
ExecutionMode: "buffered",
},
},
{
Expand Down Expand Up @@ -361,6 +361,62 @@ urlCommands:
}
}

func TestExecutionMode(t *testing.T) {
t.Parallel()

testCases := []struct {
name string
yaml string
expectedMode string
}{
{
name: "explicit executionMode buffered",
yaml: `
urlCommands:
- url: POST /test
commandTemplate: /bin/echo
executionMode: buffered
`,
expectedMode: "buffered",
},
{
name: "explicit executionMode stream",
yaml: `
urlCommands:
- url: POST /test
commandTemplate: /bin/echo
executionMode: stream
`,
expectedMode: "stream",
},
{
name: "explicit executionMode async",
yaml: `
urlCommands:
- url: POST /test
commandTemplate: /bin/echo
executionMode: async
`,
expectedMode: "async",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

cfg, err := config.LoadConfigFromString(tc.yaml)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}

if cfg.URLCommands[0].ExecutionMode != tc.expectedMode {
t.Errorf("expected ExecutionMode %q, got %q", tc.expectedMode, cfg.URLCommands[0].ExecutionMode)
}
})
}
}

func ptrBool(b bool) *bool {
return &b
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/config/test-data/test-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ urlCommands:
params:
bodyAsJson: true
timeout: 5s
outputType: text
executionMode: buffered
- url: POST /cmd/sleep
commandTemplate: |
/usr/bin/sleep
Expand Down
10 changes: 5 additions & 5 deletions pkg/router/handlers/execution_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,17 +365,17 @@ func prepareOutputAndRunCommand(
cmdResult *cmdbuilder.Result,
responseWriter http.ResponseWriter,
) error {
outputType := cmd.CommandConfig.OutputType
executionMode := cmd.CommandConfig.ExecutionMode

switch outputType {
case "none":
switch executionMode {
case "async":
return prepareOutputAndRunAsyncCommand(ctx, starter, exec, cmd, cmdResult, responseWriter)
case "stream":
return prepareOutputAndRunStreamCommand(ctx, starter, exec, cmd, cmdResult, responseWriter)
case "", "text":
case "", "buffered":
return prepareOutputAndRunSyncCommand(ctx, starter, exec, cmd, cmdResult, responseWriter)
default:
return fmt.Errorf("%w: unknown output type %q", ErrBadConfiguration, outputType)
return fmt.Errorf("%w: unknown execution mode %q", ErrBadConfiguration, executionMode)
}
}

Expand Down
42 changes: 21 additions & 21 deletions pkg/router/handlers/execution_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ func TestExecutionHandler_HappyPath_Stream(t *testing.T) {
URL: "POST /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo\n{{.url.name}}\n{{.headers.X_Test}}\n{{.body.text}}",
OutputType: "stream",
ExecutionMode: "stream",
},
}

Expand Down Expand Up @@ -101,7 +101,7 @@ func TestExecutionHandler_Text_EmptyBody(t *testing.T) {
URL: "POST /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo '{{.body.text}}'",
OutputType: "text",
ExecutionMode: "buffered",
},
}

Expand Down Expand Up @@ -162,7 +162,7 @@ func TestExecutionHandler_ExtractParams_Query_FirstValueOnly(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo {{.url.a}}",
OutputType: "text",
ExecutionMode: "buffered",
},
}

Expand Down Expand Up @@ -196,7 +196,7 @@ func TestExecutionHandler_ExtractParams_Headers_NormalizeAndJoin(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo '{{.headers.X_Test_Header}}' '{{.headers.X_Test}}'",
OutputType: "text",
ExecutionMode: "buffered",
},
}

Expand Down Expand Up @@ -393,7 +393,7 @@ func TestExecutionHandler_Stream_RequiresFlusher_500(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "stream",
ExecutionMode: "stream",
},
}

Expand All @@ -416,7 +416,7 @@ func TestExecutionHandler_Stream_RequiresFlusher_500(t *testing.T) {
}
}

func TestExecutionHandler_UnknownOutputType_500(t *testing.T) {
func TestExecutionHandler_UnknownExecutionMode_500(t *testing.T) {
t.Parallel()

fr := &fakeRunner{}
Expand All @@ -430,7 +430,7 @@ func TestExecutionHandler_UnknownOutputType_500(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "invalid",
ExecutionMode: "invalid",
},
}

Expand All @@ -445,12 +445,12 @@ func TestExecutionHandler_UnknownOutputType_500(t *testing.T) {
}

msg := rr.Header().Get("X-Error-Message")
if !strings.Contains(msg, "unknown output type") {
t.Fatalf("expected X-Error-Message to contain %q, got %q", "unknown output type", msg)
if !strings.Contains(msg, "unknown execution mode") {
t.Fatalf("expected X-Error-Message to contain %q, got %q", "unknown execution mode", msg)
}
}

func TestExecutionHandler_OutputNone_ReturnsBeforeWait(t *testing.T) {
func TestExecutionHandler_ExecutionModeAsync_ReturnsBeforeWait(t *testing.T) {
t.Parallel()

block := make(chan struct{})
Expand All @@ -467,7 +467,7 @@ func TestExecutionHandler_OutputNone_ReturnsBeforeWait(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "none",
ExecutionMode: "async",
},
}

Expand All @@ -486,7 +486,7 @@ func TestExecutionHandler_OutputNone_ReturnsBeforeWait(t *testing.T) {
case <-done:
case <-time.After(80 * time.Millisecond):
close(block)
t.Fatalf("handler did not return quickly for outputType=none")
t.Fatalf("handler did not return quickly for executionMode=async")
}

if rr.Code != http.StatusOK {
Expand Down Expand Up @@ -516,7 +516,7 @@ func TestExecutionHandler_CallGate_InvalidMode_500(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: ptrString("test-group"),
Mode: "invalid-mode",
Expand Down Expand Up @@ -554,7 +554,7 @@ func TestExecutionHandler_CallGate_Busy_429(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: ptrString("test-group"),
Mode: "single",
Expand Down Expand Up @@ -598,7 +598,7 @@ func TestExecutionHandler_CallGate_ImplicitGroupName_IsolatesDifferentURLs(t *te
URL: "GET /exec1",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: nil,
Mode: "single",
Expand All @@ -609,7 +609,7 @@ func TestExecutionHandler_CallGate_ImplicitGroupName_IsolatesDifferentURLs(t *te
URL: "GET /exec2",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: nil,
Mode: "single",
Expand Down Expand Up @@ -653,7 +653,7 @@ func TestExecutionHandler_CallGate_EmptyGroupName_SharedAcrossURLs(t *testing.T)
URL: "GET /exec1",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: ptrString(""),
Mode: "single",
Expand All @@ -664,7 +664,7 @@ func TestExecutionHandler_CallGate_EmptyGroupName_SharedAcrossURLs(t *testing.T)
URL: "GET /exec2",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: ptrString(""),
Mode: "single",
Expand Down Expand Up @@ -710,7 +710,7 @@ func TestExecutionHandler_CallGate_SharedGroupName_SharedAcrossURLs(t *testing.T
URL: "GET /exec1",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: &shared,
Mode: "single",
Expand All @@ -721,7 +721,7 @@ func TestExecutionHandler_CallGate_SharedGroupName_SharedAcrossURLs(t *testing.T
URL: "GET /exec2",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
CallGate: &config.CallGateConfig{
GroupName: &shared,
Mode: "single",
Expand Down Expand Up @@ -781,7 +781,7 @@ func TestExecutionHandler_NonZeroExit_SetsExitCodeHeader(t *testing.T) {
URL: "GET /exec",
CommandConfig: config.CommandConfig{
CommandTemplate: "echo hello",
OutputType: "text",
ExecutionMode: "buffered",
},
}

Expand Down
6 changes: 3 additions & 3 deletions test/integration/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ func TestServer_OutputTypes(t *testing.T) {
}
})

t.Run("GET /cmd/bad-output - unknown output type returns 500", func(t *testing.T) {
t.Run("GET /cmd/bad-output - unknown execution mode returns 500", func(t *testing.T) {
t.Parallel()
srv := setupServer(t)

Expand All @@ -438,8 +438,8 @@ func TestServer_OutputTypes(t *testing.T) {
}

errMsg := rec.Header().Get("X-Error-Message")
if errMsg == "" || !strings.Contains(errMsg, "unknown output type") {
t.Fatalf("Expected X-Error-Message to contain %q, got %q", "unknown output type", errMsg)
if errMsg == "" || !strings.Contains(errMsg, "unknown execution mode") {
t.Fatalf("Expected X-Error-Message to contain %q, got %q", "unknown execution mode", errMsg)
}
})
}
Expand Down
Loading