mirror of
https://github.com/adnanh/webhook.git
synced 2026-07-28 04:34:03 +00:00
feat: 添加超时限制
This commit is contained in:
parent
709674ba01
commit
531ca219fe
12 changed files with 736 additions and 22 deletions
|
|
@ -68,6 +68,10 @@ func cloneHook(src hook.Hook) hook.Hook {
|
|||
if src.HTTPMethods != nil {
|
||||
dst.HTTPMethods = append([]string(nil), src.HTTPMethods...)
|
||||
}
|
||||
if src.MaxConcurrency != nil {
|
||||
value := *src.MaxConcurrency
|
||||
dst.MaxConcurrency = &value
|
||||
}
|
||||
|
||||
return dst
|
||||
}
|
||||
|
|
@ -250,6 +254,7 @@ func normalizeAdminHook(current hook.Hook) hook.Hook {
|
|||
current.ID = strings.TrimSpace(current.ID)
|
||||
current.ExecuteCommand = strings.TrimSpace(current.ExecuteCommand)
|
||||
current.CommandWorkingDirectory = strings.TrimSpace(current.CommandWorkingDirectory)
|
||||
current.CommandTimeout = strings.TrimSpace(current.CommandTimeout)
|
||||
current.ResponseMessage = strings.TrimSpace(current.ResponseMessage)
|
||||
|
||||
if len(current.HTTPMethods) != 0 {
|
||||
|
|
@ -275,6 +280,9 @@ func upsertHookInFile(path, currentID string, updated hook.Hook) error {
|
|||
if updated.ID == "" {
|
||||
return errors.New("hook id is required")
|
||||
}
|
||||
if err := updated.ValidateExecutionSettings(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
loadedHooksMu.Lock()
|
||||
defer loadedHooksMu.Unlock()
|
||||
|
|
|
|||
|
|
@ -62,11 +62,13 @@
|
|||
hookId: byId("hookId"),
|
||||
executeCommand: byId("executeCommand"),
|
||||
commandWorkingDirectory: byId("commandWorkingDirectory"),
|
||||
commandTimeout: byId("commandTimeout"),
|
||||
httpMethods: byId("httpMethods"),
|
||||
responseMessage: byId("responseMessage"),
|
||||
incomingPayloadContentType: byId("incomingPayloadContentType"),
|
||||
successHttpResponseCode: byId("successHttpResponseCode"),
|
||||
triggerRuleMismatchHttpResponseCode: byId("triggerRuleMismatchHttpResponseCode"),
|
||||
maxConcurrency: byId("maxConcurrency"),
|
||||
captureCommandOutput: byId("captureCommandOutput"),
|
||||
captureCommandOutputOnError: byId("captureCommandOutputOnError"),
|
||||
triggerSignatureSoftFailures: byId("triggerSignatureSoftFailures"),
|
||||
|
|
@ -183,6 +185,7 @@
|
|||
id: "",
|
||||
"execute-command": "",
|
||||
"command-working-directory": "",
|
||||
"command-timeout": "",
|
||||
"response-message": "",
|
||||
"http-methods": ["POST"]
|
||||
});
|
||||
|
|
@ -743,11 +746,13 @@
|
|||
setFormValue(els.hookId, hook.id);
|
||||
setFormValue(els.executeCommand, hook["execute-command"]);
|
||||
setFormValue(els.commandWorkingDirectory, hook["command-working-directory"]);
|
||||
setFormValue(els.commandTimeout, hook["command-timeout"]);
|
||||
setFormValue(els.httpMethods, normalizeArray(hook["http-methods"]).join(", "));
|
||||
setFormValue(els.responseMessage, hook["response-message"]);
|
||||
setFormValue(els.incomingPayloadContentType, hook["incoming-payload-content-type"]);
|
||||
setFormValue(els.successHttpResponseCode, hook["success-http-response-code"] || "");
|
||||
setFormValue(els.triggerRuleMismatchHttpResponseCode, hook["trigger-rule-mismatch-http-response-code"] || "");
|
||||
setFormValue(els.maxConcurrency, hook["max-concurrency"] === 0 ? "0" : (hook["max-concurrency"] || ""));
|
||||
|
||||
els.captureCommandOutput.checked = !!hook["include-command-output-in-response"];
|
||||
els.captureCommandOutputOnError.checked = !!hook["include-command-output-in-response-on-error"];
|
||||
|
|
@ -988,6 +993,11 @@
|
|||
hook["command-working-directory"] = workingDirectory;
|
||||
}
|
||||
|
||||
var commandTimeout = inputValue(els.commandTimeout);
|
||||
if (commandTimeout) {
|
||||
hook["command-timeout"] = commandTimeout;
|
||||
}
|
||||
|
||||
var methods = splitCSV(els.httpMethods.value);
|
||||
if (methods.length) {
|
||||
hook["http-methods"] = methods;
|
||||
|
|
@ -1013,6 +1023,11 @@
|
|||
hook["trigger-rule-mismatch-http-response-code"] = mismatchCode;
|
||||
}
|
||||
|
||||
var maxConcurrency = parseOptionalInt(els.maxConcurrency.value, "Max Concurrency", errors);
|
||||
if (maxConcurrency !== null) {
|
||||
hook["max-concurrency"] = maxConcurrency;
|
||||
}
|
||||
|
||||
if (els.captureCommandOutput.checked) {
|
||||
hook["include-command-output-in-response"] = true;
|
||||
}
|
||||
|
|
@ -1172,11 +1187,13 @@
|
|||
els.hookId,
|
||||
els.executeCommand,
|
||||
els.commandWorkingDirectory,
|
||||
els.commandTimeout,
|
||||
els.httpMethods,
|
||||
els.responseMessage,
|
||||
els.incomingPayloadContentType,
|
||||
els.successHttpResponseCode,
|
||||
els.triggerRuleMismatchHttpResponseCode
|
||||
els.triggerRuleMismatchHttpResponseCode,
|
||||
els.maxConcurrency
|
||||
].forEach(function (input) {
|
||||
input.addEventListener("input", updatePreview);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -88,6 +88,10 @@
|
|||
<label for="commandWorkingDirectory">Working Directory</label>
|
||||
<input id="commandWorkingDirectory" placeholder="/srv/app">
|
||||
</div>
|
||||
<div>
|
||||
<label for="commandTimeout">Command Timeout</label>
|
||||
<input id="commandTimeout" placeholder="30s">
|
||||
</div>
|
||||
<div>
|
||||
<label for="httpMethods">HTTP Methods</label>
|
||||
<input id="httpMethods" placeholder="POST, GET">
|
||||
|
|
@ -117,6 +121,10 @@
|
|||
<label for="triggerRuleMismatchHttpResponseCode">Rule Mismatch HTTP Code</label>
|
||||
<input id="triggerRuleMismatchHttpResponseCode" inputmode="numeric" placeholder="403">
|
||||
</div>
|
||||
<div>
|
||||
<label for="maxConcurrency">Max Concurrency</label>
|
||||
<input id="maxConcurrency" inputmode="numeric" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="toggle-grid">
|
||||
<label class="toggle">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ Hooks are defined as objects in the JSON or YAML hooks configuration file. Pleas
|
|||
* `id` - specifies the ID of your hook. This value is used to create the HTTP endpoint (http://yourserver:port/hooks/your-hook-id)
|
||||
* `execute-command` - specifies the command that should be executed when the hook is triggered
|
||||
* `command-working-directory` - specifies the working directory that will be used for the script when it's executed
|
||||
* `command-timeout` - overrides the default command execution timeout for this hook. Accepts a Go duration string such as `30s`, `2m`, or `1h30m`. An empty value inherits the global `-command-timeout` flag. A value of `0` disables the timeout for this hook.
|
||||
* `max-concurrency` - overrides the default maximum number of concurrent executions for this hook. An empty value inherits the global `-max-concurrency` flag. A value of `0` disables the limit for this hook.
|
||||
* `response-message` - specifies the string that will be returned to the hook initiator
|
||||
* `response-headers` - specifies the list of headers in format `{"name": "X-Example-Header", "value": "it works"}` that will be returned in HTTP response for the hook
|
||||
* `success-http-response-code` - specifies the HTTP status code to be returned upon success
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ Usage of webhook:
|
|||
path to the HTTPS certificate pem file (default "cert.pem")
|
||||
-cipher-suites string
|
||||
comma-separated list of supported TLS cipher suites
|
||||
-command-timeout string
|
||||
default timeout for hook command execution (for example 30s); 0 disables the timeout
|
||||
-debug
|
||||
show debug output
|
||||
-header value
|
||||
|
|
@ -23,6 +25,8 @@ Usage of webhook:
|
|||
list available TLS cipher suites
|
||||
-logfile string
|
||||
send log output to a file; implicitly enables verbose logging
|
||||
-max-concurrency int
|
||||
default maximum number of concurrent executions per hook; 0 disables the limit
|
||||
-nopanic
|
||||
do not panic if hooks cannot be loaded when webhook is not running in verbose mode
|
||||
-pidfile string
|
||||
|
|
@ -53,6 +57,8 @@ Usage of webhook:
|
|||
|
||||
Use any of the above specified flags to override their default behavior.
|
||||
|
||||
`-command-timeout` and `-max-concurrency` act as defaults for all hooks. Individual hooks can override them using the `command-timeout` and `max-concurrency` hook properties. Within a hook definition, `0` explicitly disables the inherited limit.
|
||||
|
||||
# Live reloading hooks
|
||||
If you are running an OS that supports the HUP or USR1 signal, you can use it to trigger hooks reload from hooks file, without restarting the webhook instance.
|
||||
```bash
|
||||
|
|
|
|||
146
execution.go
Normal file
146
execution.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adnanh/webhook/internal/hook"
|
||||
)
|
||||
|
||||
var (
|
||||
commandTimeout = flag.String("command-timeout", "", "default timeout for hook command execution (for example 30s); 0 disables the timeout")
|
||||
maxConcurrency = flag.Int("max-concurrency", 0, "default maximum number of concurrent executions per hook; 0 disables the limit")
|
||||
defaultExecTimeout time.Duration
|
||||
|
||||
errHookConcurrencyLimit = errors.New("hook concurrency limit reached")
|
||||
)
|
||||
|
||||
type hookExecutionLimiter struct {
|
||||
limit int
|
||||
tokens chan struct{}
|
||||
}
|
||||
|
||||
var hookExecutionLimiters = struct {
|
||||
mu sync.Mutex
|
||||
byHook map[string]*hookExecutionLimiter
|
||||
}{
|
||||
byHook: make(map[string]*hookExecutionLimiter),
|
||||
}
|
||||
|
||||
func initExecutionSettings() error {
|
||||
value := strings.TrimSpace(*commandTimeout)
|
||||
switch value {
|
||||
case "", "0":
|
||||
defaultExecTimeout = 0
|
||||
default:
|
||||
timeout, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid command-timeout: %w", err)
|
||||
}
|
||||
if timeout < 0 {
|
||||
return errors.New("command-timeout must be zero or greater")
|
||||
}
|
||||
defaultExecTimeout = timeout
|
||||
}
|
||||
|
||||
if *maxConcurrency < 0 {
|
||||
return errors.New("max-concurrency must be zero or greater")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
|
||||
release, err := acquireHookExecutionSlot(h)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return executeHook(h, r, true, release)
|
||||
}
|
||||
|
||||
func handleHookAsync(h *hook.Hook, r *hook.Request) error {
|
||||
release, err := acquireHookExecutionSlot(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = executeHook(h, r, false, release)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func hookExecutionErrorStatus(err error) (int, string) {
|
||||
if errors.Is(err, errHookConcurrencyLimit) {
|
||||
return http.StatusServiceUnavailable, "Hook concurrency limit exceeded. Please try again later."
|
||||
}
|
||||
|
||||
return http.StatusInternalServerError, "Error occurred while executing the hook's command. Please check your logs for more details."
|
||||
}
|
||||
|
||||
func acquireHookExecutionSlot(h *hook.Hook) (func(), error) {
|
||||
limit, err := h.EffectiveMaxConcurrency(*maxConcurrency)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit == 0 {
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
limiter := executionLimiterForHook(h.ID, limit)
|
||||
|
||||
select {
|
||||
case limiter.tokens <- struct{}{}:
|
||||
return func() {
|
||||
<-limiter.tokens
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w (%d running)", errHookConcurrencyLimit, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func executionLimiterForHook(id string, limit int) *hookExecutionLimiter {
|
||||
hookExecutionLimiters.mu.Lock()
|
||||
defer hookExecutionLimiters.mu.Unlock()
|
||||
|
||||
limiter := hookExecutionLimiters.byHook[id]
|
||||
if limiter == nil || limiter.limit != limit {
|
||||
limiter = &hookExecutionLimiter{
|
||||
limit: limit,
|
||||
tokens: make(chan struct{}, limit),
|
||||
}
|
||||
hookExecutionLimiters.byHook[id] = limiter
|
||||
}
|
||||
|
||||
return limiter
|
||||
}
|
||||
|
||||
func hookExecutionContext(h *hook.Hook, r *hook.Request, waitForCommand bool) (context.Context, context.CancelFunc, bool, error) {
|
||||
timeout, err := h.EffectiveCommandTimeout(defaultExecTimeout)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
useContext := timeout > 0
|
||||
|
||||
if waitForCommand && r != nil && r.RawRequest != nil {
|
||||
ctx = r.RawRequest.Context()
|
||||
useContext = true
|
||||
}
|
||||
|
||||
if timeout > 0 {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
return ctx, cancel, true, nil
|
||||
}
|
||||
|
||||
return ctx, func() {}, useContext, nil
|
||||
}
|
||||
|
|
@ -567,6 +567,8 @@ type Hook struct {
|
|||
ID string `json:"id,omitempty"`
|
||||
ExecuteCommand string `json:"execute-command,omitempty"`
|
||||
CommandWorkingDirectory string `json:"command-working-directory,omitempty"`
|
||||
CommandTimeout string `json:"command-timeout,omitempty"`
|
||||
MaxConcurrency *int `json:"max-concurrency,omitempty"`
|
||||
ResponseMessage string `json:"response-message,omitempty"`
|
||||
ResponseHeaders ResponseHeaders `json:"response-headers,omitempty"`
|
||||
CaptureCommandOutput bool `json:"include-command-output-in-response,omitempty"`
|
||||
|
|
@ -584,6 +586,69 @@ type Hook struct {
|
|||
KeepFileEnvironment bool `json:"keep-file-environment,omitempty"`
|
||||
}
|
||||
|
||||
func parseCommandTimeout(value string) (time.Duration, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || value == "0" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
timeout, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if timeout < 0 {
|
||||
return 0, errors.New("must be zero or greater")
|
||||
}
|
||||
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
// ValidateExecutionSettings normalizes and validates hook execution limits.
|
||||
func (h *Hook) ValidateExecutionSettings() error {
|
||||
h.CommandTimeout = strings.TrimSpace(h.CommandTimeout)
|
||||
|
||||
if h.CommandTimeout != "" {
|
||||
if _, err := parseCommandTimeout(h.CommandTimeout); err != nil {
|
||||
return fmt.Errorf("invalid command-timeout %q: %w", h.CommandTimeout, err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.MaxConcurrency != nil && *h.MaxConcurrency < 0 {
|
||||
return fmt.Errorf("invalid max-concurrency %d: must be zero or greater", *h.MaxConcurrency)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EffectiveCommandTimeout returns the hook timeout or the inherited default.
|
||||
func (h Hook) EffectiveCommandTimeout(defaultTimeout time.Duration) (time.Duration, error) {
|
||||
if defaultTimeout < 0 {
|
||||
return 0, errors.New("default command timeout must be zero or greater")
|
||||
}
|
||||
|
||||
if h.CommandTimeout == "" {
|
||||
return defaultTimeout, nil
|
||||
}
|
||||
|
||||
return parseCommandTimeout(h.CommandTimeout)
|
||||
}
|
||||
|
||||
// EffectiveMaxConcurrency returns the hook concurrency limit or the inherited default.
|
||||
func (h Hook) EffectiveMaxConcurrency(defaultLimit int) (int, error) {
|
||||
if defaultLimit < 0 {
|
||||
return 0, errors.New("default max concurrency must be zero or greater")
|
||||
}
|
||||
|
||||
if h.MaxConcurrency == nil {
|
||||
return defaultLimit, nil
|
||||
}
|
||||
if *h.MaxConcurrency < 0 {
|
||||
return 0, errors.New("max-concurrency must be zero or greater")
|
||||
}
|
||||
|
||||
return *h.MaxConcurrency, nil
|
||||
}
|
||||
|
||||
// ParseJSONParameters decodes specified arguments to JSON objects and replaces the
|
||||
// string with the newly created object
|
||||
func (h *Hook) ParseJSONParameters(r *Request) []error {
|
||||
|
|
@ -775,7 +840,21 @@ func (h *Hooks) LoadFromFile(path string, asTemplate bool) error {
|
|||
file = buf.Bytes()
|
||||
}
|
||||
|
||||
return yaml.Unmarshal(file, h)
|
||||
if err := yaml.Unmarshal(file, h); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i := range *h {
|
||||
if err := (*h)[i].ValidateExecutionSettings(); err != nil {
|
||||
id := (*h)[i].ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("#%d", i)
|
||||
}
|
||||
return fmt.Errorf("invalid hook %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Append appends hooks unless the new hooks contain a hook with an ID that already exists
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGetParameter(t *testing.T) {
|
||||
|
|
@ -351,20 +352,21 @@ func TestHookExtractCommandArguments(t *testing.T) {
|
|||
// we test both cases where the name of the data is used as the name of the
|
||||
// env key & the case where the hook definition sets the env var name to a
|
||||
// fixed value using the envname construct like so::
|
||||
// [
|
||||
// {
|
||||
// "id": "push",
|
||||
// "execute-command": "bb2mm",
|
||||
// "command-working-directory": "/tmp",
|
||||
// "pass-environment-to-command":
|
||||
// [
|
||||
// {
|
||||
// "source": "entire-payload",
|
||||
// "envname": "PAYLOAD"
|
||||
// },
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
//
|
||||
// [
|
||||
// {
|
||||
// "id": "push",
|
||||
// "execute-command": "bb2mm",
|
||||
// "command-working-directory": "/tmp",
|
||||
// "pass-environment-to-command":
|
||||
// [
|
||||
// {
|
||||
// "source": "entire-payload",
|
||||
// "envname": "PAYLOAD"
|
||||
// },
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
var hookExtractCommandArgumentsForEnvTests = []struct {
|
||||
exec string
|
||||
args []Argument
|
||||
|
|
@ -412,6 +414,108 @@ func TestHookExtractCommandArgumentsForEnv(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
func TestHookValidateExecutionSettings(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
hook Hook
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid values",
|
||||
hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(2)},
|
||||
},
|
||||
{
|
||||
name: "explicit unlimited values",
|
||||
hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)},
|
||||
},
|
||||
{
|
||||
name: "invalid timeout",
|
||||
hook: Hook{CommandTimeout: "not-a-duration"},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid concurrency",
|
||||
hook: Hook{MaxConcurrency: intPtr(-1)},
|
||||
wantErr: true,
|
||||
},
|
||||
} {
|
||||
err := tt.hook.ValidateExecutionSettings()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("%s: expected error=%v, got err=%v", tt.name, tt.wantErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookEffectiveExecutionSettings(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
hook Hook
|
||||
defaultTimeout time.Duration
|
||||
defaultConcurrency int
|
||||
wantTimeout time.Duration
|
||||
wantConcurrency int
|
||||
wantTimeoutErr bool
|
||||
wantConcurrencyErr bool
|
||||
}{
|
||||
{
|
||||
name: "inherits defaults",
|
||||
hook: Hook{},
|
||||
defaultTimeout: 5 * time.Second,
|
||||
defaultConcurrency: 3,
|
||||
wantTimeout: 5 * time.Second,
|
||||
wantConcurrency: 3,
|
||||
},
|
||||
{
|
||||
name: "hook overrides defaults",
|
||||
hook: Hook{CommandTimeout: "250ms", MaxConcurrency: intPtr(1)},
|
||||
defaultTimeout: 5 * time.Second,
|
||||
defaultConcurrency: 3,
|
||||
wantTimeout: 250 * time.Millisecond,
|
||||
wantConcurrency: 1,
|
||||
},
|
||||
{
|
||||
name: "hook disables defaults",
|
||||
hook: Hook{CommandTimeout: "0", MaxConcurrency: intPtr(0)},
|
||||
defaultTimeout: 5 * time.Second,
|
||||
defaultConcurrency: 3,
|
||||
wantTimeout: 0,
|
||||
wantConcurrency: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid timeout override",
|
||||
hook: Hook{CommandTimeout: "bogus"},
|
||||
wantTimeoutErr: true,
|
||||
wantConcurrency: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid default concurrency",
|
||||
hook: Hook{},
|
||||
defaultConcurrency: -1,
|
||||
wantConcurrencyErr: true,
|
||||
},
|
||||
} {
|
||||
timeout, timeoutErr := tt.hook.EffectiveCommandTimeout(tt.defaultTimeout)
|
||||
if (timeoutErr != nil) != tt.wantTimeoutErr {
|
||||
t.Fatalf("%s: expected timeout error=%v, got err=%v", tt.name, tt.wantTimeoutErr, timeoutErr)
|
||||
}
|
||||
if timeoutErr == nil && timeout != tt.wantTimeout {
|
||||
t.Fatalf("%s: expected timeout %v, got %v", tt.name, tt.wantTimeout, timeout)
|
||||
}
|
||||
|
||||
concurrency, concurrencyErr := tt.hook.EffectiveMaxConcurrency(tt.defaultConcurrency)
|
||||
if (concurrencyErr != nil) != tt.wantConcurrencyErr {
|
||||
t.Fatalf("%s: expected concurrency error=%v, got err=%v", tt.name, tt.wantConcurrencyErr, concurrencyErr)
|
||||
}
|
||||
if concurrencyErr == nil && concurrency != tt.wantConcurrency {
|
||||
t.Fatalf("%s: expected concurrency %d, got %d", tt.name, tt.wantConcurrency, concurrency)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hooksLoadFromFileTests = []struct {
|
||||
path string
|
||||
asTemplate bool
|
||||
|
|
|
|||
|
|
@ -555,6 +555,79 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "command-timeout-default",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"include-command-output-in-response": true,
|
||||
"include-command-output-in-response-on-error": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "command-timeout-hook",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-timeout": "100ms",
|
||||
"include-command-output-in-response": true,
|
||||
"include-command-output-in-response-on-error": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "command-timeout-unlimited",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"command-timeout": "0",
|
||||
"include-command-output-in-response": true,
|
||||
"include-command-output-in-response-on-error": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "max-concurrency-default",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"include-command-output-in-response": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "max-concurrency-hook",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"max-concurrency": 1,
|
||||
"include-command-output-in-response": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "max-concurrency-unlimited",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
"max-concurrency": 0,
|
||||
"include-command-output-in-response": true,
|
||||
"pass-arguments-to-command": [
|
||||
{
|
||||
"source": "string",
|
||||
"name": "sleep=250ms"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty-payload-signature",
|
||||
"execute-command": "{{ .Hookecho }}",
|
||||
|
|
|
|||
|
|
@ -318,6 +318,55 @@
|
|||
- source: string
|
||||
name: cat-env-file=HOOK_FILE_PKG-NAME
|
||||
|
||||
- id: command-timeout-default
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
include-command-output-in-response: true
|
||||
include-command-output-in-response-on-error: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: command-timeout-hook
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
command-timeout: 100ms
|
||||
include-command-output-in-response: true
|
||||
include-command-output-in-response-on-error: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: command-timeout-unlimited
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
command-timeout: '0'
|
||||
include-command-output-in-response: true
|
||||
include-command-output-in-response-on-error: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: max-concurrency-default
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
include-command-output-in-response: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: max-concurrency-hook
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
max-concurrency: 1
|
||||
include-command-output-in-response: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: max-concurrency-unlimited
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
max-concurrency: 0
|
||||
include-command-output-in-response: true
|
||||
pass-arguments-to-command:
|
||||
- source: string
|
||||
name: sleep=250ms
|
||||
|
||||
- id: empty-payload-signature
|
||||
include-command-output-in-response: true
|
||||
execute-command: '{{ .Hookecho }}'
|
||||
|
|
|
|||
39
webhook.go
39
webhook.go
|
|
@ -96,6 +96,11 @@ func main() {
|
|||
|
||||
flag.Parse()
|
||||
|
||||
if err := initExecutionSettings(); err != nil {
|
||||
fmt.Println("error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *justDisplayVersion {
|
||||
fmt.Println("webhook version " + version)
|
||||
os.Exit(0)
|
||||
|
|
@ -542,12 +547,14 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
response, err := handleHook(matchedHook, req)
|
||||
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
if matchedHook.CaptureCommandOutputOnError {
|
||||
status, message := hookExecutionErrorStatus(err)
|
||||
if matchedHook.CaptureCommandOutputOnError && response != "" {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, response)
|
||||
} else {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
fmt.Fprint(w, "Error occurred while executing the hook's command. Please check your logs for more details.")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, message)
|
||||
}
|
||||
} else {
|
||||
// Check if a success return code is configured for the hook
|
||||
|
|
@ -557,7 +564,13 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
fmt.Fprint(w, response)
|
||||
}
|
||||
} else {
|
||||
go handleHook(matchedHook, req)
|
||||
if err := handleHookAsync(matchedHook, req); err != nil {
|
||||
status, message := hookExecutionErrorStatus(err)
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprint(w, message)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a success return code is configured for the hook
|
||||
if matchedHook.SuccessHttpResponseCode != 0 {
|
||||
|
|
@ -580,7 +593,9 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
|
|||
fmt.Fprint(w, "Hook rules were not satisfied.")
|
||||
}
|
||||
|
||||
func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
|
||||
func executeHook(h *hook.Hook, r *hook.Request, waitForCommand bool, release func()) (string, error) {
|
||||
defer release()
|
||||
|
||||
var errors []error
|
||||
|
||||
// check the command exists
|
||||
|
|
@ -604,7 +619,19 @@ func handleHook(h *hook.Hook, r *hook.Request) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
|
||||
cmd := exec.Command(cmdPath)
|
||||
ctx, cancel, useContext, err := hookExecutionContext(h, r, waitForCommand)
|
||||
if err != nil {
|
||||
log.Printf("[%s] error preparing command execution: %s", r.ID, err)
|
||||
return "", err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useContext {
|
||||
cmd = exec.CommandContext(ctx, cmdPath)
|
||||
} else {
|
||||
cmd = exec.Command(cmdPath)
|
||||
}
|
||||
cmd.Dir = h.CommandWorkingDirectory
|
||||
|
||||
cmd.Args, errors = h.ExtractCommandArguments(r)
|
||||
|
|
|
|||
195
webhook_test.go
195
webhook_test.go
|
|
@ -495,6 +495,201 @@ func TestWebhookKeepFileEnvironment(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookCommandTimeout(t *testing.T) {
|
||||
hookecho, cleanupHookecho := buildHookecho(t)
|
||||
defer cleanupHookecho()
|
||||
|
||||
webhookBin, cleanupWebhook := buildWebhook(t)
|
||||
defer cleanupWebhook()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id string
|
||||
extraArgs []string
|
||||
wantStatus int
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
minElapsed time.Duration
|
||||
maxElapsed time.Duration
|
||||
}{
|
||||
{
|
||||
name: "global default timeout",
|
||||
id: "command-timeout-default",
|
||||
extraArgs: []string{"-command-timeout=100ms"},
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantContains: nil,
|
||||
wantNotContains: []string{"slept:"},
|
||||
maxElapsed: 220 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "hook timeout",
|
||||
id: "command-timeout-hook",
|
||||
wantStatus: http.StatusInternalServerError,
|
||||
wantContains: nil,
|
||||
wantNotContains: []string{"slept:"},
|
||||
maxElapsed: 220 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "hook timeout override disabled",
|
||||
id: "command-timeout-unlimited",
|
||||
extraArgs: []string{"-command-timeout=100ms"},
|
||||
wantStatus: http.StatusOK,
|
||||
wantContains: []string{"arg: sleep=250ms", "slept: 250ms"},
|
||||
wantNotContains: nil,
|
||||
minElapsed: 220 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} {
|
||||
configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl)
|
||||
defer cleanupConfig()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) {
|
||||
cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...)
|
||||
defer killAndWait(cmd)
|
||||
|
||||
start := time.Now()
|
||||
status, body := doJSONHookRequest(t, baseURL, tt.id)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if status != tt.wantStatus {
|
||||
t.Fatalf("expected status %d, got %d: %s", tt.wantStatus, status, body)
|
||||
}
|
||||
for _, want := range tt.wantContains {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("response missing %q: %s", want, body)
|
||||
}
|
||||
}
|
||||
for _, notWant := range tt.wantNotContains {
|
||||
if strings.Contains(body, notWant) {
|
||||
t.Fatalf("response unexpectedly contained %q: %s", notWant, body)
|
||||
}
|
||||
}
|
||||
if tt.minElapsed > 0 && elapsed < tt.minElapsed {
|
||||
t.Fatalf("request completed too quickly: got %v, want >= %v", elapsed, tt.minElapsed)
|
||||
}
|
||||
if tt.maxElapsed > 0 && elapsed > tt.maxElapsed {
|
||||
t.Fatalf("request completed too slowly: got %v, want <= %v", elapsed, tt.maxElapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookMaxConcurrency(t *testing.T) {
|
||||
hookecho, cleanupHookecho := buildHookecho(t)
|
||||
defer cleanupHookecho()
|
||||
|
||||
webhookBin, cleanupWebhook := buildWebhook(t)
|
||||
defer cleanupWebhook()
|
||||
|
||||
limitedTests := []struct {
|
||||
name string
|
||||
id string
|
||||
extraArgs []string
|
||||
limitNeedle string
|
||||
}{
|
||||
{
|
||||
name: "global default limit",
|
||||
id: "max-concurrency-default",
|
||||
extraArgs: []string{"-max-concurrency=1"},
|
||||
limitNeedle: "Hook concurrency limit exceeded. Please try again later.",
|
||||
},
|
||||
{
|
||||
name: "hook limit",
|
||||
id: "max-concurrency-hook",
|
||||
extraArgs: nil,
|
||||
limitNeedle: "Hook concurrency limit exceeded. Please try again later.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, hookTmpl := range []string{"test/hooks.json.tmpl", "test/hooks.yaml.tmpl"} {
|
||||
configPath, cleanupConfig := genConfig(t, hookecho, hookTmpl)
|
||||
defer cleanupConfig()
|
||||
|
||||
for _, tt := range limitedTests {
|
||||
t.Run(tt.name+"@"+hookTmpl, func(t *testing.T) {
|
||||
cmd, logs, baseURL := startWebhookServer(t, webhookBin, configPath, tt.extraArgs...)
|
||||
defer killAndWait(cmd)
|
||||
|
||||
type result struct {
|
||||
status int
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
firstDone := make(chan result, 1)
|
||||
go func() {
|
||||
status, body, err := doJSONHookRequestResult(baseURL, tt.id)
|
||||
firstDone <- result{status: status, body: body, err: err}
|
||||
}()
|
||||
|
||||
waitForBufferContains(t, logs, "executing", time.Second)
|
||||
|
||||
secondStatus, secondBody := doJSONHookRequest(t, baseURL, tt.id)
|
||||
firstResult := <-firstDone
|
||||
if firstResult.err != nil {
|
||||
t.Fatalf("first request failed: %v", firstResult.err)
|
||||
}
|
||||
|
||||
if secondStatus != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected second request to return 503, got %d: %s", secondStatus, secondBody)
|
||||
}
|
||||
if !strings.Contains(secondBody, tt.limitNeedle) {
|
||||
t.Fatalf("expected second response to contain %q: %s", tt.limitNeedle, secondBody)
|
||||
}
|
||||
if firstResult.status != http.StatusOK {
|
||||
t.Fatalf("expected first request to succeed, got %d: %s", firstResult.status, firstResult.body)
|
||||
}
|
||||
if !strings.Contains(firstResult.body, "slept: 250ms") {
|
||||
t.Fatalf("expected first response to contain sleep output: %s", firstResult.body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("hook limit override disabled@"+hookTmpl, func(t *testing.T) {
|
||||
cmd, _, baseURL := startWebhookServer(t, webhookBin, configPath, "-max-concurrency=1")
|
||||
defer killAndWait(cmd)
|
||||
|
||||
type result struct {
|
||||
status int
|
||||
body string
|
||||
err error
|
||||
}
|
||||
|
||||
results := make(chan result, 2)
|
||||
start := time.Now()
|
||||
for i := 0; i < 2; i++ {
|
||||
go func() {
|
||||
status, body, err := doJSONHookRequestResult(baseURL, "max-concurrency-unlimited")
|
||||
results <- result{status: status, body: body, err: err}
|
||||
}()
|
||||
}
|
||||
|
||||
first := <-results
|
||||
second := <-results
|
||||
elapsed := time.Since(start)
|
||||
|
||||
for _, result := range []result{first, second} {
|
||||
if result.err != nil {
|
||||
t.Fatalf("request failed: %v", result.err)
|
||||
}
|
||||
if result.status != http.StatusOK {
|
||||
t.Fatalf("expected request to succeed, got %d: %s", result.status, result.body)
|
||||
}
|
||||
if !strings.Contains(result.body, "slept: 250ms") {
|
||||
t.Fatalf("expected response to contain sleep output: %s", result.body)
|
||||
}
|
||||
}
|
||||
if elapsed > 450*time.Millisecond {
|
||||
t.Fatalf("expected unlimited override to allow parallel execution, took %v", elapsed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var hookHandlerTests = []struct {
|
||||
desc string
|
||||
id string
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue