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
286 changes: 286 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
package main

import (
"bytes"
"context"
"flag"
"io"
"net/url"
"os"
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// parseFlagsForTest parses args into a fresh flag set and returns the resulting Config.
// The slice and host flags accumulate across parses, so they are cleared first.
func parseFlagsForTest(t *testing.T, args []string) (Config, error) {
t.Helper()
resetGlobals(t)

templatesFlag, stdoutTailFlag, stderrTailFlag = nil, nil, nil
headersFlag, waitFlag, delimsFlag = nil, nil, ""

oldCommandLine, oldUsage := flag.CommandLine, flag.Usage
t.Cleanup(func() {
flag.CommandLine, flag.Usage = oldCommandLine, oldUsage
})
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)

registerFlags()
require.NoError(t, flag.CommandLine.Parse(args))
return parseConfigFromFlags()
}

func urlStrings(urls []url.URL) []string {
out := make([]string, 0, len(urls))
for _, u := range urls {
out = append(out, u.String())
}
return out
}

func TestParseConfigFromFlags(t *testing.T) {
t.Run("defaults", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{})
require.NoError(t, err)

assert.False(t, cfg.version)
assert.False(t, cfg.poll)
assert.False(t, cfg.noOverwrite)
assert.Equal(t, 10*time.Second, cfg.waitTimeout)
assert.Equal(t, defaultWaitRetryInterval, cfg.waitRetryInterval)
assert.Empty(t, cfg.templates)
assert.Empty(t, cfg.stdoutTails)
assert.Empty(t, cfg.stderrTails)
assert.Empty(t, cfg.delims)
assert.Empty(t, cfg.headers)
assert.Empty(t, cfg.urls)
assert.Empty(t, cfg.waits)
assert.Empty(t, cfg.args)
})

t.Run("full flag set", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{
"-template", "a.tmpl:/etc/a.conf",
"-template", "dir:/etc/conf.d",
"-wait", "tcp://db:5432",
"-wait", "http://web:8000/health",
"-wait", "file:///tmp/ready",
"-wait-http-header", "Accept-Encoding: gzip",
"-wait-http-header", "X-Token:abc",
"-delims", "<%:%>",
"-timeout", "5s",
"-wait-retry-interval", "2s",
"-no-overwrite",
"-poll",
"-stdout", "/var/log/a.log",
"-stdout", "/var/log/b.log",
"-stderr", "/var/log/err.log",
"nginx", "-g", "daemon off;",
})
require.NoError(t, err)

assert.True(t, cfg.poll)
assert.True(t, cfg.noOverwrite)
assert.False(t, cfg.version)
assert.Equal(t, []string{"a.tmpl:/etc/a.conf", "dir:/etc/conf.d"}, cfg.templates)
assert.Equal(t, []string{"/var/log/a.log", "/var/log/b.log"}, cfg.stdoutTails)
assert.Equal(t, []string{"/var/log/err.log"}, cfg.stderrTails)
assert.Equal(t, []string{"Accept-Encoding: gzip", "X-Token:abc"}, cfg.headersFlag)
assert.Equal(t, []string{"<%", "%>"}, cfg.delims)
assert.Equal(t, []HttpHeader{
{name: "Accept-Encoding", value: "gzip"},
{name: "X-Token", value: "abc"},
}, cfg.headers)
assert.Equal(t, []string{"tcp://db:5432", "http://web:8000/health", "file:///tmp/ready"}, cfg.waits)
assert.Equal(t, []string{"tcp://db:5432", "http://web:8000/health", "file:///tmp/ready"}, urlStrings(cfg.urls))
assert.Equal(t, "tcp", cfg.urls[0].Scheme)
assert.Equal(t, "http", cfg.urls[1].Scheme)
assert.Equal(t, "file", cfg.urls[2].Scheme)
assert.Equal(t, 5*time.Second, cfg.waitTimeout)
assert.Equal(t, 2*time.Second, cfg.waitRetryInterval)
assert.Equal(t, []string{"nginx", "-g", "daemon off;"}, cfg.args)
})

t.Run("version flag", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{"-version"})
require.NoError(t, err)
assert.True(t, cfg.version)
})

t.Run("bad delimiters", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{"-delims", "a:b:c"})
assert.EqualError(t, err, `bad delimiters argument: a:b:c. expected "left:right"`)
assert.Equal(t, Config{}, cfg)
})

t.Run("header without wait", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{"-wait-http-header", "Accept: text/html"})
assert.ErrorContains(t, err, `-wait-http-header "Accept: text/html" provided with no -wait option`)
assert.Equal(t, Config{}, cfg)
})

t.Run("header without colon", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{"-wait", "tcp://db:5432", "-wait-http-header", "Accept"})
assert.ErrorContains(t, err, `bad HTTP Headers argument: Accept.`)
assert.Equal(t, Config{}, cfg)
})

t.Run("bad wait URL", func(t *testing.T) {
cfg, err := parseFlagsForTest(t, []string{"-wait", "http://[::1"})
assert.ErrorContains(t, err, "bad hostname provided: http://[::1.")
assert.Equal(t, Config{}, cfg)
})
}

func TestParseConfigFromFlagsRestoresCommandLine(t *testing.T) {
before := flag.CommandLine
t.Run("parse", func(t *testing.T) {
_, err := parseFlagsForTest(t, []string{})
require.NoError(t, err)
})
assert.Same(t, before, flag.CommandLine)
}

func TestStartCommandRunsCommand(t *testing.T) {
resetGlobals(t)
oldCommandLine := flag.CommandLine
t.Cleanup(func() { flag.CommandLine = oldCommandLine })
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
require.NoError(t, flag.CommandLine.Parse([]string{"true"}))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

startCommand(ctx, cancel)

waitOrFail(t, "command did not finish")
assert.Error(t, ctx.Err(), "context should be cancelled once the command exits")
}

func TestStartCommandWithoutArgsAddsNothing(t *testing.T) {
resetGlobals(t)
oldCommandLine := flag.CommandLine
t.Cleanup(func() { flag.CommandLine = oldCommandLine })
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
require.NoError(t, flag.CommandLine.Parse([]string{}))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

startCommand(ctx, cancel)

waitOrFail(t, "wait group should be empty")
assert.NoError(t, ctx.Err(), "context should not be cancelled")
}

func TestStartTailersStopsWhenContextCanceled(t *testing.T) {
resetGlobals(t)
dir := t.TempDir()
stdoutPath := filepath.Join(dir, "stdout.log")
stderrPath := filepath.Join(dir, "stderr.log")
require.NoError(t, os.WriteFile(stdoutPath, []byte{}, 0o644))
require.NoError(t, os.WriteFile(stderrPath, []byte{}, 0o644))

stdoutTailFlag = sliceVar{stdoutPath}
stderrTailFlag = sliceVar{stderrPath}
poll = true

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

startTailers(ctx)
time.Sleep(100 * time.Millisecond)
cancel()

waitOrFail(t, "tailers did not stop after context cancellation")
}

// waitOrFail waits for wg with a timeout, failing the test with msg if it does not finish.
func waitOrFail(t *testing.T, msg string) {
t.Helper()
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()

select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal(msg)
}
}

func TestUsage(t *testing.T) {
if fatalCase("usage") {
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
registerFlags()
usage()
return
}

// usage writes with the builtin println, which bypasses os.Stderr, so its text is checked in a child process.
usage()

exitCode, stderr := runFatalCase(t, "TestUsage", "usage")
assert.Equal(t, 0, exitCode)
assert.Contains(t, stderr, "Usage: dockerize [options] [command]")
assert.Contains(t, stderr, "-wait-http-header")
assert.Contains(t, stderr, "For more information, see https://github.com/jwilder/dockerize")
}

func TestMainPrintsVersion(t *testing.T) {
resetGlobals(t)
oldArgs, oldCommandLine, oldUsage, oldBuildVersion := os.Args, flag.CommandLine, flag.Usage, buildVersion
t.Cleanup(func() {
os.Args, flag.CommandLine, flag.Usage, buildVersion = oldArgs, oldCommandLine, oldUsage, oldBuildVersion
})
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
os.Args = []string{"dockerize", "-version"}
buildVersion = "v1.2.3"

oldStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
t.Cleanup(func() { os.Stdout = oldStdout })

main()

require.NoError(t, w.Close())
os.Stdout = oldStdout
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
assert.Equal(t, "v1.2.3\n", buf.String())
}

func TestMainFatalCases(t *testing.T) {
if fatalCase("no-args") {
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
os.Args = []string{"dockerize"}
main()
}
if fatalCase("bad-delims") {
flag.CommandLine = flag.NewFlagSet("dockerize", flag.ContinueOnError)
os.Args = []string{"dockerize", "-delims", "a:b:c"}
main()
}

t.Run("no args prints usage and exits 1", func(t *testing.T) {
exitCode, stderr := runFatalCase(t, "TestMainFatalCases", "no-args")
assert.Equal(t, 1, exitCode)
assert.Contains(t, stderr, "Usage: dockerize [options] [command]")
})

t.Run("bad delimiters exit 1 with error", func(t *testing.T) {
exitCode, stderr := runFatalCase(t, "TestMainFatalCases", "bad-delims")
assert.Equal(t, 1, exitCode)
assert.Contains(t, stderr, `bad delimiters argument: a:b:c. expected "left:right"`)
})
}
4 changes: 3 additions & 1 deletion exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ func runCmd(ctx context.Context, cancel context.CancelFunc, cmd string, args ...

}

var signalKillTimeout = 10 * time.Second

func signalProcessWithTimeout(process *exec.Cmd, sig os.Signal, waitDone ...<-chan struct{}) {
// pretty sure this doesn't do anything. It seems like the signal is automatically sent to the command?
if err := process.Process.Signal(sig); err != nil && !errors.Is(err, os.ErrProcessDone) {
Expand All @@ -79,7 +81,7 @@ func signalProcessWithTimeout(process *exec.Cmd, sig os.Signal, waitDone ...<-ch
select {
case <-waitDone[0]:
return
case <-time.After(10 * time.Second):
case <-time.After(signalKillTimeout):
log.Println("Killing command due to timeout.")
if err := process.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
log.Printf("Error killing command: %s\n", err)
Expand Down
92 changes: 92 additions & 0 deletions exec_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"bufio"
"os/exec"
"syscall"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSignalKillTimeoutDefault(t *testing.T) {
assert.Equal(t, 10*time.Second, signalKillTimeout)
}

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

cmd := exec.Command("sleep", "30")
require.NoError(t, cmd.Start())

signalProcessWithTimeout(cmd, syscall.SIGTERM)

require.NotNil(t, cmd.ProcessState)
status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
require.True(t, ok)
assert.True(t, status.Signaled())
assert.Equal(t, syscall.SIGTERM, status.Signal())
}

func TestSignalProcessWithTimeoutKillsAfterTimeout(t *testing.T) {
// Not parallel: it overrides the package-level signalKillTimeout.
oldTimeout := signalKillTimeout
signalKillTimeout = 200 * time.Millisecond
t.Cleanup(func() { signalKillTimeout = oldTimeout })

// exec replaces the shell with sleep, so the ignored TERM disposition
// is inherited and SIGKILL is the only thing that stops it.
cmd := exec.Command("sh", "-c", `trap "" TERM; echo ready; exec sleep 30`)
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())

// Wait until the trap is installed before signalling.
line, err := bufio.NewReader(stdout).ReadString('\n')
require.NoError(t, err)
require.Equal(t, "ready\n", line)

// Reap the process in our own goroutine: the kill branch returns without
// waiting, so reading ProcessState afterwards would race with cmd.Wait.
waitDone := make(chan struct{})
go func() {
_ = cmd.Wait()
close(waitDone)
}()

start := time.Now()
signalProcessWithTimeout(cmd, syscall.SIGTERM, waitDone)
assert.Less(t, time.Since(start), 2*time.Second)

select {
case <-waitDone:
case <-time.After(5 * time.Second):
t.Fatal("process was not killed after the timeout")
}

status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus)
require.True(t, ok)
assert.True(t, status.Signaled())
assert.Equal(t, syscall.SIGKILL, status.Signal())
}

func TestSignalProcessWithTimeoutReturnsWhenAlreadyDone(t *testing.T) {
// Not parallel: it overrides the package-level signalKillTimeout.
oldTimeout := signalKillTimeout
signalKillTimeout = 5 * time.Second
t.Cleanup(func() { signalKillTimeout = oldTimeout })

cmd := exec.Command("sleep", "30")
require.NoError(t, cmd.Start())

waitDone := make(chan struct{})
close(waitDone)

start := time.Now()
signalProcessWithTimeout(cmd, syscall.SIGTERM, waitDone)
assert.Less(t, time.Since(start), time.Second)

_ = cmd.Wait()
}
Loading
Loading