1
0
Fork 0
mirror of https://github.com/adnanh/webhook.git synced 2026-07-28 04:34:03 +00:00

feat: 添加超时限制

This commit is contained in:
jason.liao 2026-04-28 13:47:51 +08:00
parent 709674ba01
commit 531ca219fe
12 changed files with 736 additions and 22 deletions

View file

@ -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()

View file

@ -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);
});

View file

@ -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">

View file

@ -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

View file

@ -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
View 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
}

View file

@ -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

View file

@ -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

View file

@ -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 }}",

View file

@ -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 }}'

View file

@ -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)

View file

@ -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