Skip to content

Commit 807e96a

Browse files
committed
Add streaming command support.
Add options - `stream-stdout-in-response` - `stream-stdout-in-response-on-error` - `stream-command-kill-grace-period-seconds` to allow defining webhooks which dynamically stream large content back to the requestor. This allows the creation of download endpoints from scripts, i.e. running a `git archive` command or a database dump from a docker container, without needing to buffer up the original.
1 parent 20fb3e3 commit 807e96a

File tree

7 files changed

+163
-37
lines changed

7 files changed

+163
-37
lines changed

hook/hook.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,6 @@ type Hook struct {
387387
CaptureCommandOutputOnError bool `json:"include-command-output-in-response-on-error,omitempty"`
388388
StreamCommandStdout bool `json:"stream-stdout-in-response,omitempty"`
389389
StreamCommandStderrOnError bool `json:"stream-stderr-in-response-on-error,omitempty"`
390-
//KillCommandOnWriteError bool `json:"kill-command-on-write-error,omitempty"`
391390
StreamCommandKillGraceSecs float64 `json:"stream-command-kill-grace-period-seconds,omitempty"`
392391
PassEnvironmentToCommand []Argument `json:"pass-environment-to-command,omitempty"`
393392
PassArgumentsToCommand []Argument `json:"pass-arguments-to-command,omitempty"`

test/hookecho.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,37 +7,47 @@ import (
77
"os"
88
"strings"
99
"strconv"
10+
"io"
1011
)
1112

1213
func checkPrefix(prefixMap map[string]struct{}, prefix string, arg string) bool {
1314
if _, found := prefixMap[prefix]; found {
1415
fmt.Printf("prefix specified more then once: %s", arg)
1516
os.Exit(-1)
1617
}
17-
prefixMap[prefix] = struct{}{}
18-
return strings.HasPrefix(arg, "stream=")
18+
19+
if strings.HasPrefix(arg, prefix) {
20+
prefixMap[prefix] = struct{}{}
21+
return true
22+
}
23+
24+
return false
1925
}
2026

2127
func main() {
22-
outputStream := os.Stdout
28+
var outputStream io.Writer
29+
outputStream = os.Stdout
2330
seenPrefixes := make(map[string]struct{})
2431
exit_code := 0
2532

26-
for _, arg := range os.Args {
33+
for _, arg := range os.Args[1:] {
2734
if checkPrefix(seenPrefixes, "stream=", arg) {
2835
switch arg {
2936
case "stream=stdout":
3037
outputStream = os.Stdout
3138
case "stream=stderr":
3239
outputStream = os.Stderr
40+
case "stream=both":
41+
outputStream = io.MultiWriter(os.Stdout, os.Stderr)
3342
default:
3443
fmt.Printf("unrecognized stream specification: %s", arg)
3544
os.Exit(-1)
3645
}
3746
} else if checkPrefix(seenPrefixes, "exit=", arg) {
38-
exit_code_str := os.Args[1][5:]
47+
exit_code_str := arg[5:]
3948
var err error
40-
exit_code, err = strconv.Atoi(exit_code_str)
49+
exit_code_conv, err := strconv.Atoi(exit_code_str)
50+
exit_code = exit_code_conv
4151
if err != nil {
4252
fmt.Printf("Exit code %s not an int!", exit_code_str)
4353
os.Exit(-1)

test/hooks.json.tmpl

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,18 +182,22 @@
182182
"include-command-output-in-response-on-error": true
183183
},
184184
{
185-
"id": "steam-stdout-in-response",
185+
"id": "stream-stdout-in-response",
186186
"pass-arguments-to-command": [
187187
{
188188
"source": "string",
189189
"name": "exit=0"
190+
},
191+
{
192+
"source": "string",
193+
"name": "stream=both"
190194
}
191195
],
192196
"execute-command": "{{ .Hookecho }}",
193197
"stream-stdout-in-response": true
194198
},
195199
{
196-
"id": "steam-stderr-in-response-on-error",
200+
"id": "stream-stderr-in-response-on-error",
197201
"pass-arguments-to-command": [
198202
{
199203
"source": "string",

test/hooks.yaml.tmpl

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,15 @@
9797
execute-command: '{{ .Hookecho }}'
9898
include-command-output-in-response: true
9999
include-command-output-in-response-on-error: true
100-
- id: steam-stdout-in-response
100+
- id: stream-stdout-in-response
101101
execute-command: '{{ .Hookecho }}'
102102
stream-stdout-in-response: true
103103
pass-arguments-to-command:
104104
- source: string
105105
name: exit=0
106-
- id: steam-stderr-in-response-on-error
106+
- source: string
107+
name: stream=both
108+
- id: stream-stderr-in-response-on-error
107109
execute-command: '{{ .Hookecho }}'
108110
stream-stdout-in-response: true
109111
stream-stderr-in-response-on-error: true

test/hookstream/hookstream.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Hook Stream is a simple utility for testing Webhook streaming capability. It spawns a TCP server on execution
2+
// which echos all connections to its stdout until it receives the string EOF.
3+
4+
package main
5+
6+
import (
7+
"fmt"
8+
"os"
9+
"strings"
10+
"strconv"
11+
"io"
12+
"net"
13+
"bufio"
14+
)
15+
16+
func checkPrefix(prefixMap map[string]struct{}, prefix string, arg string) bool {
17+
if _, found := prefixMap[prefix]; found {
18+
fmt.Printf("prefix specified more then once: %s", arg)
19+
os.Exit(-1)
20+
}
21+
22+
if strings.HasPrefix(arg, prefix) {
23+
prefixMap[prefix] = struct{}{}
24+
return true
25+
}
26+
27+
return false
28+
}
29+
30+
func main() {
31+
var outputStream io.Writer
32+
outputStream = os.Stdout
33+
seenPrefixes := make(map[string]struct{})
34+
exit_code := 0
35+
36+
for _, arg := range os.Args[1:] {
37+
if checkPrefix(seenPrefixes, "stream=", arg) {
38+
switch arg {
39+
case "stream=stdout":
40+
outputStream = os.Stdout
41+
case "stream=stderr":
42+
outputStream = os.Stderr
43+
case "stream=both":
44+
outputStream = io.MultiWriter(os.Stdout, os.Stderr)
45+
default:
46+
fmt.Printf("unrecognized stream specification: %s", arg)
47+
os.Exit(-1)
48+
}
49+
} else if checkPrefix(seenPrefixes, "exit=", arg) {
50+
exit_code_str := arg[5:]
51+
var err error
52+
exit_code_conv, err := strconv.Atoi(exit_code_str)
53+
exit_code = exit_code_conv
54+
if err != nil {
55+
fmt.Printf("Exit code %s not an int!", exit_code_str)
56+
os.Exit(-1)
57+
}
58+
}
59+
}
60+
61+
l, err := net.Listen("tcp", "localhost:0")
62+
if err != nil {
63+
fmt.Printf("Error starting tcp server: %v\n", err)
64+
os.Exit(-1)
65+
}
66+
defer l.Close()
67+
68+
// Emit the address of the server
69+
fmt.Printf("%v\n",l.Addr())
70+
71+
manageCh := make(chan struct{})
72+
73+
go func() {
74+
for {
75+
conn, err := l.Accept()
76+
if err != nil {
77+
fmt.Printf("Error accepting connection: %v\n", err)
78+
os.Exit(-1)
79+
}
80+
go handleRequest(manageCh, outputStream, conn)
81+
}
82+
}()
83+
84+
<- manageCh
85+
l.Close()
86+
87+
os.Exit(exit_code)
88+
}
89+
90+
// Handles incoming requests.
91+
func handleRequest(manageCh chan<- struct{}, w io.Writer, conn net.Conn) {
92+
defer conn.Close()
93+
bio := bufio.NewScanner(conn)
94+
for bio.Scan() {
95+
if line := strings.TrimSuffix(bio.Text(), "\n"); line == "EOF" {
96+
// Request program close
97+
select {
98+
case manageCh <- struct{}{}:
99+
// Request sent.
100+
default:
101+
// Already closing
102+
}
103+
break
104+
}
105+
fmt.Fprintf(w, "%s\n", bio.Text())
106+
}
107+
}

webhook.go

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,6 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
236236

237237
log.Printf("[%s] incoming HTTP request from %s\n", rid, r.RemoteAddr)
238238

239-
for _, responseHeader := range responseHeaders {
240-
w.Header().Set(responseHeader.Name, responseHeader.Value)
241-
}
242-
243239
id := mux.Vars(r)["id"]
244240

245241
matchedHook := matchLoadedHook(id)
@@ -345,6 +341,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
345341
stdoutRdr, stderrRdr, errCh := handleHook(ctx, matchedHook, rid, &headers, &query, &payload, &body)
346342

347343
if matchedHook.StreamCommandStdout {
344+
log.Printf("[%s] Hook (%s) is a streaming command hook\n", rid, matchedHook.ID)
348345
// Collect stderr to avoid blocking processes and emit it as a string
349346
stderrRdy := make(chan string, 1)
350347
go func() {
@@ -362,17 +359,18 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
362359

363360
// Streaming output should commence as soon as the command execution tries to write any data
364361
firstByte := make([]byte,1)
365-
_, err := stdoutRdr.Read(firstByte)
366-
if err != nil {
367-
w.WriteHeader(http.StatusInternalServerError)
362+
_, fbErr := stdoutRdr.Read(firstByte)
363+
if fbErr != nil && fbErr != io.EOF {
368364
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
365+
w.WriteHeader(http.StatusInternalServerError)
369366
fmt.Fprintf(w, "Error occurred while trying to read from the process's first byte. Please check your logs for more details.")
370-
}
371-
372-
// Did the process throw an error before we read this byte?
373-
select {
374-
case err := <- errCh:
375-
if err != nil {
367+
log.Printf("[%s] Hook error while reading first byte: %v\n", rid, err)
368+
return
369+
} else if fbErr == io.EOF {
370+
log.Printf("[%s] EOF from hook stdout while reading first byte. Waiting for program exit status\n", rid)
371+
if err := <- errCh; err != nil {
372+
log.Printf("[%s] Hook (%s) returned an error before the first byte. Collecting stderr and failing.\n", rid, matchedHook.ID)
373+
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
376374
w.WriteHeader(http.StatusInternalServerError)
377375
if matchedHook.StreamCommandStderrOnError {
378376
// Wait for the stderr buffer to finish collecting
@@ -382,24 +380,22 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
382380
return
383381
}
384382
} else {
385-
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
386383
fmt.Fprintf(w, "Error occurred while executing the hooks command. Please check your logs for more details.")
387384
}
388385
return // Cannot proceed beyond here
389386
}
390-
default:
391-
// no error - continue
387+
// early EOF, but program exited successfully so stream as normal.
392388
}
393389

394-
// Got the first byte (or possibly nothing) successfully. Write the success header, then commence
395-
// streaming.
396-
w.WriteHeader(http.StatusOK)
397-
398390
// Write user success headers
399391
for _, responseHeader := range matchedHook.ResponseHeaders {
400392
w.Header().Set(responseHeader.Name, responseHeader.Value)
401393
}
402394

395+
// Got the first byte (or possibly nothing) successfully. Write the success header, then commence
396+
// streaming.
397+
w.WriteHeader(http.StatusOK)
398+
403399
if _, err := w.Write(firstByte); err != nil {
404400
// Hard fail, client has disconnected or otherwise we can't continue.
405401
msg := fmt.Sprintf("[%s] error while trying to stream first byte: %s", rid, err)
@@ -418,6 +414,7 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
418414
log.Printf(msg)
419415

420416
} else {
417+
log.Printf("[%s] Hook (%s) is a conventional command hook\n", rid, matchedHook.ID)
421418
// Don't break the original API and just combine the streams (specifically, kick off two readers which
422419
// break on newlines and the emit that data in temporal order to the output buffer.
423420
out := combinedOutput(stdoutRdr, stderrRdr)
@@ -426,13 +423,15 @@ func hookHandler(w http.ResponseWriter, r *http.Request) {
426423

427424
err := <-errCh
428425

426+
log.Printf("[%s] got command execution result: %v", rid, err)
427+
429428
if err != nil {
430429
w.WriteHeader(http.StatusInternalServerError)
431430
} else {
432-
w.WriteHeader(http.StatusOK)
433431
for _, responseHeader := range matchedHook.ResponseHeaders {
434432
w.Header().Set(responseHeader.Name, responseHeader.Value)
435433
}
434+
w.WriteHeader(http.StatusOK)
436435
}
437436

438437
if matchedHook.CaptureCommandOutput {
@@ -584,9 +583,10 @@ body *[]byte) (io.Reader, io.Reader, <-chan error) {
584583

585584
// Spawn a goroutine to wait for the command to end supply errors
586585
go func() {
587-
err := cmd.Wait()
588-
if err != nil {
589-
log.Printf("[%s] error occurred: %+v\n", rid, err)
586+
resultErr := cmd.Wait()
587+
close(doneCh) // Close the doneCh immediately so handlers exit correctly.
588+
if resultErr != nil {
589+
log.Printf("[%s] error occurred: %+v\n", rid, resultErr)
590590
}
591591

592592
for i := range files {
@@ -601,9 +601,8 @@ body *[]byte) (io.Reader, io.Reader, <-chan error) {
601601

602602
log.Printf("[%s] finished handling %s\n", rid, h.ID)
603603

604-
errCh <- err
604+
errCh <- resultErr
605605
close(errCh)
606-
close(doneCh)
607606
}()
608607

609608
// Spawn a goroutine which checks if the context is ever cancelled, and sends SIGTERM / SIGKILL if it is
@@ -612,6 +611,7 @@ body *[]byte) (io.Reader, io.Reader, <-chan error) {
612611

613612
select {
614613
case <- ctxDone:
614+
log.Printf("[%s] Context done (request finished) - killing process.", rid)
615615
// AFAIK this works on Win/Mac/Unix - where does it not work?
616616
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
617617
log.Printf("[%s] error sending SIGTERM to process for %s: %s\n", rid, h.ID, err)

webhook_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,5 +636,9 @@ env: HOOK_head_commit.timestamp=2013-03-12T08:14:29-07:00
636636
`},
637637
{"don't capture output on error by default", "capture-command-output-on-error-not-by-default", nil, `{}`, false, http.StatusInternalServerError, `Error occurred while executing the hook's command. Please check your logs for more details.`},
638638
{"capture output on error with extra flag set", "capture-command-output-on-error-yes-with-extra-flag", nil, `{}`, false, http.StatusInternalServerError, `arg: exit=1
639+
`},
640+
{"streaming response yields stdout only", "stream-stdout-in-response", nil, `{}`, false, http.StatusOK, `arg: exit=0 stream=both
641+
`},
642+
{"streaming response with an error yields stderr", "stream-stderr-in-response-on-error", nil, `{}`, false, http.StatusInternalServerError, `arg: exit=1 stream=stderr
639643
`},
640644
}

0 commit comments

Comments
 (0)