From 8b3f0563861ef78a5866031f1e5c714a983faa77 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 01:03:42 -0500 Subject: [PATCH 01/19] Initial setup for integration tests --- ci/integration/integration_test.go | 28 ++++ ci/tcli/tcli.go | 212 +++++++++++++++++++++++++++++ go.mod | 1 + 3 files changed, 241 insertions(+) create mode 100644 ci/integration/integration_test.go create mode 100644 ci/tcli/tcli.go diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go new file mode 100644 index 00000000..188fdccb --- /dev/null +++ b/ci/integration/integration_test.go @@ -0,0 +1,28 @@ +package integration + +import ( + "context" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" +) + +func TestTCli(t *testing.T) { + ctx := context.Background() + + container := tcli.NewRunContainer(ctx, "", "test-container") + + container.Run(ctx, "echo testing").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("esting"), + ) + + container.Run(ctx, "sleep 1.5 && echo 1>&2 stderr-message").Assert(t, + tcli.Success(), + tcli.StdoutEmpty(), + tcli.StderrMatches("message"), + tcli.DurationGreaterThan(time.Second), + ) +} diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go new file mode 100644 index 00000000..f03a02c8 --- /dev/null +++ b/ci/tcli/tcli.go @@ -0,0 +1,212 @@ +package tcli + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "regexp" + "testing" + "time" + + "cdr.dev/slog/sloggers/slogtest/assert" + "golang.org/x/xerrors" +) + +type RunContainer struct { +} + +func NewRunContainer(ctx context.Context, image, name string) *RunContainer { + //exec.CommandContext(ctx, "docker", "start") + // TODO: startup docker container + return &RunContainer{} +} + +func (r RunContainer) Teardown() error { + // TODO: teardown run environment + return nil +} + +type Assertable struct { + cmd string + ctx context.Context +} + +func (*RunContainer) Run(ctx context.Context, cmd string) *Assertable { + return &Assertable{ + cmd: cmd, + ctx: ctx, + } +} + +func (a Assertable) Assert(t *testing.T, option ...Assertion) { + var cmdResult CommandResult + + cmd := exec.CommandContext(a.ctx, "sh", "-c", a.cmd) + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + err := cmd.Run() + cmdResult.Duration = time.Since(start) + + if exitErr, ok := err.(*exec.ExitError); ok { + cmdResult.ExitCode = exitErr.ExitCode() + } else if err != nil { + cmdResult.ExitCode = -1 + } else { + cmdResult.ExitCode = 0 + } + + cmdResult.Stdout = stdout.Bytes() + cmdResult.Stderr = stderr.Bytes() + + for ix, o := range option { + name := fmt.Sprintf("assertion_#%v", ix) + if named, ok := o.(Named); ok { + name = named.Name() + } + t.Run(name, func(t *testing.T) { + err := o.Valid(cmdResult) + assert.Success(t, name, err) + }) + } +} + +type Assertion interface { + Valid(r CommandResult) error +} + +type Named interface { + Name() string +} + +type CommandResult struct { + Stdout, Stderr []byte + ExitCode int + Duration time.Duration +} + +type simpleFuncAssert struct { + valid func(r CommandResult) error + name string +} + +func (s simpleFuncAssert) Valid(r CommandResult) error { + return s.valid(r) +} + +func (s simpleFuncAssert) Name() string { + return s.name +} + +func Success() Assertion { + return ExitCodeIs(0) +} + +func ExitCodeIs(code int) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + if r.ExitCode != code { + return xerrors.Errorf("exit code of %s expected, got %v", code, r.ExitCode) + } + return nil + }, + name: fmt.Sprintf("exitcode"), + } +} + +func StdoutEmpty() Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + return empty("stdout", r.Stdout) + }, + name: fmt.Sprintf("stdout-empty"), + } +} + +func StderrEmpty() Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + return empty("stderr", r.Stderr) + }, + name: fmt.Sprintf("stderr-empty"), + } +} + +func StdoutMatches(pattern string) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + return matches("stdout", pattern, r.Stdout) + }, + name: fmt.Sprintf("stdout-matches"), + } +} + +func StderrMatches(pattern string) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + return matches("stderr", pattern, r.Stderr) + }, + name: fmt.Sprintf("stderr-matches"), + } +} + +func CombinedMatches(pattern string) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + //stdoutValid := StdoutMatches(pattern).Valid(r) + //stderrValid := StderrMatches(pattern).Valid(r) + // TODO: combine errors + return nil + }, + name: fmt.Sprintf("combined-matches"), + } +} + +func matches(name, pattern string, target []byte) error { + ok, err := regexp.Match(pattern, target) + if err != nil { + return xerrors.Errorf("failed to attempt regexp match: %w", err) + } + if !ok { + return xerrors.Errorf("expected to find pattern (%s) in %s, no match found", pattern, name) + } + return nil +} + +func empty(name string, a []byte) error { + if len(a) > 0 { + return xerrors.Errorf("expected %s to be empty, got (%s)", name, string(a)) + } + return nil +} + +func DurationLessThan(dur time.Duration) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + if r.Duration > dur { + return xerrors.Errorf("expected duration less than %s, took %s", dur.String(), r.Duration.String()) + } + return nil + }, + name: fmt.Sprintf("duration-lessthan"), + } +} + +func DurationGreaterThan(dur time.Duration) Assertion { + return simpleFuncAssert{ + valid: func(r CommandResult) error { + if r.Duration < dur { + return xerrors.Errorf("expected duration greater than %s, took %s", dur.String(), r.Duration.String()) + } + return nil + }, + name: fmt.Sprintf("duration-greaterthan"), + } +} diff --git a/go.mod b/go.mod index 7d8feec2..4f496165 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module cdr.dev/coder-cli go 1.14 require ( + cdr.dev/slog v1.3.0 cdr.dev/wsep v0.0.0-20200728013649-82316a09813f github.com/fatih/color v1.9.0 // indirect github.com/gorilla/websocket v1.4.1 From 8e8b215e1a5949c93611632749ad8f534d4bc0e1 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 01:31:23 -0500 Subject: [PATCH 02/19] Execute commands in container --- ci/integration/integration_test.go | 5 ++- ci/tcli/tcli.go | 58 +++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 188fdccb..5ecdeb7e 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -6,12 +6,15 @@ import ( "time" "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" ) func TestTCli(t *testing.T) { ctx := context.Background() - container := tcli.NewRunContainer(ctx, "", "test-container") + container, err := tcli.NewRunContainer(ctx, "ubuntu:latest", "test-container") + assert.Success(t, "new run container", err) + defer container.Close() container.Run(ctx, "echo testing").Assert(t, tcli.Success(), diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index f03a02c8..80fa6383 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -6,6 +6,7 @@ import ( "fmt" "os/exec" "regexp" + "strings" "testing" "time" @@ -14,35 +15,68 @@ import ( ) type RunContainer struct { + name string + ctx context.Context } -func NewRunContainer(ctx context.Context, image, name string) *RunContainer { - //exec.CommandContext(ctx, "docker", "start") - // TODO: startup docker container - return &RunContainer{} +func NewRunContainer(ctx context.Context, image, name string) (*RunContainer, error) { + cmd := exec.CommandContext(ctx, + "docker", "run", + "--name", name, + "-it", "-d", + image, + ) + + out, err := cmd.CombinedOutput() + if err != nil { + return nil, xerrors.Errorf( + "failed to start testing container %q, (%s): %w", + name, string(out), err) + } + + return &RunContainer{ + name: name, + ctx: ctx, + }, nil } -func (r RunContainer) Teardown() error { - // TODO: teardown run environment +func (r *RunContainer) Close() error { + cmd := exec.CommandContext(r.ctx, + "sh", "-c", strings.Join([]string{ + "docker", "kill", r.name, "&&", + "docker", "rm", r.name, + }, " ")) + + out, err := cmd.CombinedOutput() + if err != nil { + return xerrors.Errorf( + "failed to stop testing container %q, (%s): %w", + r.name, string(out), err) + } return nil } type Assertable struct { - cmd string - ctx context.Context + cmd string + ctx context.Context + container *RunContainer } -func (*RunContainer) Run(ctx context.Context, cmd string) *Assertable { +func (r *RunContainer) Run(ctx context.Context, cmd string) *Assertable { return &Assertable{ - cmd: cmd, - ctx: ctx, + cmd: cmd, + ctx: ctx, + container: r, } } func (a Assertable) Assert(t *testing.T, option ...Assertion) { var cmdResult CommandResult - cmd := exec.CommandContext(a.ctx, "sh", "-c", a.cmd) + cmd := exec.CommandContext(a.ctx, + "docker", "exec", a.container.name, + "sh", "-c", a.cmd, + ) var ( stdout bytes.Buffer stderr bytes.Buffer From 2932fe9188ea0ca1ba28816792ef0b31e3928089 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 09:38:20 -0500 Subject: [PATCH 03/19] Add RunCmd manual command --- ci/integration/integration_test.go | 17 +++++- ci/tcli/tcli.go | 84 +++++++++++++++++++++++------- 2 files changed, 81 insertions(+), 20 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 5ecdeb7e..7b8a90e5 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -2,6 +2,8 @@ package integration import ( "context" + "os/exec" + "strings" "testing" "time" @@ -12,7 +14,11 @@ import ( func TestTCli(t *testing.T) { ctx := context.Background() - container, err := tcli.NewRunContainer(ctx, "ubuntu:latest", "test-container") + container, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ + Image: "ubuntu:latest", + Name: "test-container", + }) + assert.Success(t, "new run container", err) defer container.Close() @@ -28,4 +34,13 @@ func TestTCli(t *testing.T) { tcli.StderrMatches("message"), tcli.DurationGreaterThan(time.Second), ) + + cmd := exec.CommandContext(ctx, "cat") + cmd.Stdin = strings.NewReader("testing") + + container.RunCmd(cmd).Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("testing"), + ) } diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 80fa6383..40d7ac6d 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -19,23 +19,51 @@ type RunContainer struct { ctx context.Context } -func NewRunContainer(ctx context.Context, image, name string) (*RunContainer, error) { - cmd := exec.CommandContext(ctx, - "docker", "run", - "--name", name, +type ContainerConfig struct { + Name string + Image string + Mounts map[string]string +} + +func mountArgs(m map[string]string) (args []string) { + for src, dest := range m { + args = append(args, "--mount", fmt.Sprintf("source=%s,target=%s", src, dest)) + } + return args +} + +func preflightChecks() error { + _, err := exec.LookPath("docker") + if err != nil { + return xerrors.Errorf(`"docker" not found in $PATH`) + } + return nil +} + +func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContainer, error) { + if err := preflightChecks(); err != nil { + return nil, err + } + + args := []string{ + "run", + "--name", config.Name, "-it", "-d", - image, - ) + } + args = append(args, mountArgs(config.Mounts)...) + args = append(args, config.Image) + + cmd := exec.CommandContext(ctx, "docker", args...) out, err := cmd.CombinedOutput() if err != nil { return nil, xerrors.Errorf( "failed to start testing container %q, (%s): %w", - name, string(out), err) + config.Name, string(out), err) } return &RunContainer{ - name: name, + name: config.Name, ctx: ctx, }, nil } @@ -57,12 +85,18 @@ func (r *RunContainer) Close() error { } type Assertable struct { - cmd string + cmd *exec.Cmd ctx context.Context container *RunContainer } -func (r *RunContainer) Run(ctx context.Context, cmd string) *Assertable { +// Run executes the given command in the runtime container with reasonable defaults +func (r *RunContainer) Run(ctx context.Context, command string) *Assertable { + cmd := exec.CommandContext(ctx, + "docker", "exec", "-i", r.name, + "sh", "-c", command, + ) + return &Assertable{ cmd: cmd, ctx: ctx, @@ -70,23 +104,32 @@ func (r *RunContainer) Run(ctx context.Context, cmd string) *Assertable { } } +// RunCmd lifts the given *exec.Cmd into the runtime container +func (r *RunContainer) RunCmd(cmd *exec.Cmd) *Assertable { + path, _ := exec.LookPath("docker") + cmd.Path = path + command := strings.Join(cmd.Args, " ") + cmd.Args = append([]string{"docker", "exec", "-i", r.name, "sh", "-c", command}) + + return &Assertable{ + cmd: cmd, + container: r, + } +} + func (a Assertable) Assert(t *testing.T, option ...Assertion) { var cmdResult CommandResult - cmd := exec.CommandContext(a.ctx, - "docker", "exec", a.container.name, - "sh", "-c", a.cmd, - ) var ( stdout bytes.Buffer stderr bytes.Buffer ) - cmd.Stdout = &stdout - cmd.Stderr = &stderr + a.cmd.Stdout = &stdout + a.cmd.Stderr = &stderr start := time.Now() - err := cmd.Run() + err := a.cmd.Run() cmdResult.Duration = time.Since(start) if exitErr, ok := err.(*exec.ExitError); ok { @@ -147,7 +190,7 @@ func ExitCodeIs(code int) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { if r.ExitCode != code { - return xerrors.Errorf("exit code of %s expected, got %v", code, r.ExitCode) + return xerrors.Errorf("exit code of %v expected, got %v", code, r.ExitCode) } return nil }, @@ -209,7 +252,10 @@ func matches(name, pattern string, target []byte) error { return xerrors.Errorf("failed to attempt regexp match: %w", err) } if !ok { - return xerrors.Errorf("expected to find pattern (%s) in %s, no match found", pattern, name) + return xerrors.Errorf( + "expected to find pattern (%s) in %s, no match found in (%v)", + pattern, name, string(target), + ) } return nil } From 025e1bdfc1e512997560264b5648eea69cdb61cc Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 10:00:36 -0500 Subject: [PATCH 04/19] Adds working bind mount --- .gitignore | 1 + ci/integration/integration_test.go | 30 ++++++++++++++++++++++++++++++ ci/tcli/tcli.go | 10 +++++----- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 5fd924f8..05e4aa48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea ci/bin cmd/coder/coder +ci/integration/bin diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 7b8a90e5..1813ed0f 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -2,7 +2,10 @@ package integration import ( "context" + "fmt" + "os" "os/exec" + "path/filepath" "strings" "testing" "time" @@ -11,12 +14,33 @@ import ( "cdr.dev/slog/sloggers/slogtest/assert" ) +func build(t *testing.T, path string) { + cmd := exec.Command( + "sh", "-c", + fmt.Sprintf("cd ../../ && go build -o %s ./cmd/coder", path), + ) + cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0") + + out, err := cmd.CombinedOutput() + t.Logf("%s", string(out)) + assert.Success(t, "build go binary", err) +} + func TestTCli(t *testing.T) { ctx := context.Background() + cwd, err := os.Getwd() + assert.Success(t, "get working dir", err) + + binpath := filepath.Join(cwd, "bin", "coder") + build(t, binpath) + container, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ Image: "ubuntu:latest", Name: "test-container", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, }) assert.Success(t, "new run container", err) @@ -43,4 +67,10 @@ func TestTCli(t *testing.T) { tcli.StderrEmpty(), tcli.StdoutMatches("testing"), ) + + container.Run(ctx, "which coder").Assert(t, + tcli.Success(), + tcli.StdoutMatches("/bin/coder"), + tcli.StderrEmpty(), + ) } diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 40d7ac6d..9b9cd95a 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -20,14 +20,14 @@ type RunContainer struct { } type ContainerConfig struct { - Name string - Image string - Mounts map[string]string + Name string + Image string + BindMounts map[string]string } func mountArgs(m map[string]string) (args []string) { for src, dest := range m { - args = append(args, "--mount", fmt.Sprintf("source=%s,target=%s", src, dest)) + args = append(args, "--mount", fmt.Sprintf("type=bind,source=%s,target=%s", src, dest)) } return args } @@ -50,7 +50,7 @@ func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContaine "--name", config.Name, "-it", "-d", } - args = append(args, mountArgs(config.Mounts)...) + args = append(args, mountArgs(config.BindMounts)...) args = append(args, config.Image) cmd := exec.CommandContext(ctx, "docker", args...) From f11304ba32c6d27f9d0caf875e48163890440a92 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 10:32:40 -0500 Subject: [PATCH 05/19] Adds github fmt, lint, test ci --- .github/workflows/build.yaml | 2 +- .github/workflows/test.yaml | 46 ++++++++++++++++++++++++++++++++++++ ci/image/Dockerfile | 8 +++++++ ci/{ => steps}/build.sh | 4 ++-- ci/steps/fmt.sh | 16 +++++++++++++ ci/steps/lint.sh | 6 +++++ 6 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/test.yaml create mode 100644 ci/image/Dockerfile rename ci/{ => steps}/build.sh (94%) create mode 100755 ci/steps/fmt.sh create mode 100755 ci/steps/lint.sh diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d246670f..11dec656 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,7 +9,7 @@ jobs: - name: Checkout uses: actions/checkout@v1 - name: Build - run: ./ci/build.sh + run: ./ci/steps/build.sh - name: Upload uses: actions/upload-artifact@v2 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..d169aec5 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,46 @@ +name: ci +on: [push, pull_request] + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: fmt + uses: ./ci/image + with: + args: ./ci/steps/fmt.sh + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: lint + uses: ./ci/image + with: + args: ./ci/steps/lint.sh + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: test + uses: ./ci/image + with: + args: go test ./... diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile new file mode 100644 index 00000000..cb06bf81 --- /dev/null +++ b/ci/image/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1 + +ENV GOFLAGS="-mod=readonly" +ENV CI=true + +RUN go get golang.org/x/tools/cmd/goimports +RUN go get golang.org/x/lint/golint +RUN go get github.com/mattn/goveralls \ No newline at end of file diff --git a/ci/build.sh b/ci/steps/build.sh similarity index 94% rename from ci/build.sh rename to ci/steps/build.sh index 8a6f540c..8411ccde 100755 --- a/ci/build.sh +++ b/ci/steps/build.sh @@ -14,14 +14,14 @@ mkdir -p bin build(){ tmpdir=$(mktemp -d) - go build -ldflags "-s -w -X main.version=${tag}" -o "$tmpdir/coder" ../cmd/coder + go build -ldflags "-s -w -X main.version=${tag}" -o "$tmpdir/coder" ../../cmd/coder pushd "$tmpdir" tarname="coder-cli-$GOOS-$GOARCH.tar.gz" tar -czf "$tarname" coder popd - cp "$tmpdir/$tarname" bin + cp "$tmpdir/$tarname" ../bin rm -rf "$tmpdir" } diff --git a/ci/steps/fmt.sh b/ci/steps/fmt.sh new file mode 100755 index 00000000..8309a2c3 --- /dev/null +++ b/ci/steps/fmt.sh @@ -0,0 +1,16 @@ +#!/bin/bash +echo "Formatting..." + +go mod tidy +gofmt -w -s . +goimports -w "-local=$$(go list -m)" . + +if [ "$CI" != "" ]; then + if [[ $(git ls-files --other --modified --exclude-standard) != "" ]]; then + echo "Files need generation or are formatted incorrectly:" + git -c color.ui=always status | grep --color=no '\e\[31m' + echo "Please run the following locally:" + echo " ./ci/fmt.sh" + exit 1 + fi +fi \ No newline at end of file diff --git a/ci/steps/lint.sh b/ci/steps/lint.sh new file mode 100755 index 00000000..51da081d --- /dev/null +++ b/ci/steps/lint.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +echo "Linting..." + +go vet ./... +golint -set_exit_status ./... \ No newline at end of file From 5998ff13adca65de5c99bcc3272d15bcc290e66f Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 11:13:17 -0500 Subject: [PATCH 06/19] Add integration action --- .github/workflows/integration.yaml | 23 +++++++++++++++++++++++ .github/workflows/test.yaml | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/integration.yaml diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 00000000..cff8699a --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,23 @@ +name: ci +on: [push, pull_request] + +jobs: + integration: + runs-on: ubuntu-latest + env: + CODER_URL: ${{ secrets.CODER_URL }} + CODER_EMAIL: ${{ secrets.CODER_EMAIL }} + CODER_PASSWORD: ${{ secrets.CODER_PASSWORD }} + steps: + - uses: actions/checkout@v1 + - uses: actions/cache@v1 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - uses: actions/setup-go@v2 + with: + go-version: '^1.14' + - name: go test + run: go test ./ci/integration/... diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index d169aec5..fb7e32db 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -43,4 +43,4 @@ jobs: - name: test uses: ./ci/image with: - args: go test ./... + args: go test ./internal/... ./cmd/... From 149586748b7e795d90756f7884625e09aaf2e4d4 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 13:55:36 -0500 Subject: [PATCH 07/19] Add initial coder tests --- ci/integration/integration_test.go | 63 +++++++++++++++++++++++++----- ci/tcli/tcli.go | 59 +++++++++++++++------------- 2 files changed, 84 insertions(+), 38 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 1813ed0f..9eb1d8ef 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -14,26 +14,37 @@ import ( "cdr.dev/slog/sloggers/slogtest/assert" ) -func build(t *testing.T, path string) { +func build(path string) error { cmd := exec.Command( "sh", "-c", fmt.Sprintf("cd ../../ && go build -o %s ./cmd/coder", path), ) cmd.Env = append(os.Environ(), "GOOS=linux", "CGO_ENABLED=0") - out, err := cmd.CombinedOutput() - t.Logf("%s", string(out)) - assert.Success(t, "build go binary", err) + _, err := cmd.CombinedOutput() + if err != nil { + return err + } + return nil } -func TestTCli(t *testing.T) { - ctx := context.Background() +var binpath string +func init() { cwd, err := os.Getwd() - assert.Success(t, "get working dir", err) + if err != nil { + panic(err) + } + + binpath = filepath.Join(cwd, "bin", "coder") + err = build(binpath) + if err != nil { + panic(err) + } +} - binpath := filepath.Join(cwd, "bin", "coder") - build(t, binpath) +func TestTCli(t *testing.T) { + ctx := context.Background() container, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ Image: "ubuntu:latest", @@ -42,7 +53,6 @@ func TestTCli(t *testing.T) { binpath: "/bin/coder", }, }) - assert.Success(t, "new run container", err) defer container.Close() @@ -73,4 +83,37 @@ func TestTCli(t *testing.T) { tcli.StdoutMatches("/bin/coder"), tcli.StderrEmpty(), ) + + container.Run(ctx, "coder version").Assert(t, + tcli.StderrEmpty(), + tcli.Success(), + tcli.StdoutMatches("linux"), + ) +} + +func TestCoderCLI(t *testing.T) { + ctx := context.Background() + + c, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ + Image: "ubuntu:latest", + Name: "test-container", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, + }) + assert.Success(t, "new run container", err) + defer c.Close() + + c.Run(ctx, "coder version").Assert(t, + tcli.StderrEmpty(), + tcli.Success(), + tcli.StdoutMatches("linux"), + ) + + c.Run(ctx, "coder help").Assert(t, + tcli.Success(), + tcli.StderrMatches("Commands:"), + tcli.StderrMatches("Usage: coder"), + tcli.StdoutEmpty(), + ) } diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 9b9cd95a..b5f35404 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -118,41 +118,44 @@ func (r *RunContainer) RunCmd(cmd *exec.Cmd) *Assertable { } func (a Assertable) Assert(t *testing.T, option ...Assertion) { - var cmdResult CommandResult + t.Run(strings.Join(a.cmd.Args[6:], " "), func(t *testing.T) { + var cmdResult CommandResult - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) - a.cmd.Stdout = &stdout - a.cmd.Stderr = &stderr + a.cmd.Stdout = &stdout + a.cmd.Stderr = &stderr - start := time.Now() - err := a.cmd.Run() - cmdResult.Duration = time.Since(start) + start := time.Now() + err := a.cmd.Run() + cmdResult.Duration = time.Since(start) - if exitErr, ok := err.(*exec.ExitError); ok { - cmdResult.ExitCode = exitErr.ExitCode() - } else if err != nil { - cmdResult.ExitCode = -1 - } else { - cmdResult.ExitCode = 0 - } + if exitErr, ok := err.(*exec.ExitError); ok { + cmdResult.ExitCode = exitErr.ExitCode() + } else if err != nil { + cmdResult.ExitCode = -1 + } else { + cmdResult.ExitCode = 0 + } - cmdResult.Stdout = stdout.Bytes() - cmdResult.Stderr = stderr.Bytes() + cmdResult.Stdout = stdout.Bytes() + cmdResult.Stderr = stderr.Bytes() - for ix, o := range option { - name := fmt.Sprintf("assertion_#%v", ix) - if named, ok := o.(Named); ok { - name = named.Name() + for ix, o := range option { + name := fmt.Sprintf("assertion_#%v", ix) + if named, ok := o.(Named); ok { + name = named.Name() + } + t.Run(name, func(t *testing.T) { + t.Parallel() + err := o.Valid(cmdResult) + assert.Success(t, name, err) + }) } - t.Run(name, func(t *testing.T) { - err := o.Valid(cmdResult) - assert.Success(t, name, err) - }) - } + }) } type Assertion interface { From a7ac5237504c5bc8b38b72302902ca2a34c80e5a Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 14:54:39 -0500 Subject: [PATCH 08/19] Doc comments only --- ci/steps/fmt.sh | 2 +- ci/tcli/tcli.go | 20 ++++++++++++++++++++ cmd/coder/sync.go | 7 +++---- internal/activity/pusher.go | 5 ++++- internal/activity/writer.go | 2 ++ internal/config/file.go | 5 +++++ internal/entclient/activity.go | 1 + internal/entclient/client.go | 1 + internal/entclient/devurl.go | 10 ++++++---- internal/entclient/env.go | 4 ++++ internal/entclient/me.go | 4 ++++ internal/entclient/org.go | 2 ++ internal/loginsrv/server.go | 1 + internal/sync/sync.go | 1 + internal/xterminal/terminal.go | 1 + 15 files changed, 56 insertions(+), 10 deletions(-) diff --git a/ci/steps/fmt.sh b/ci/steps/fmt.sh index 8309a2c3..bb4b0d2c 100755 --- a/ci/steps/fmt.sh +++ b/ci/steps/fmt.sh @@ -10,7 +10,7 @@ if [ "$CI" != "" ]; then echo "Files need generation or are formatted incorrectly:" git -c color.ui=always status | grep --color=no '\e\[31m' echo "Please run the following locally:" - echo " ./ci/fmt.sh" + echo " ./ci/steps/fmt.sh" exit 1 fi fi \ No newline at end of file diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index b5f35404..574e3bf8 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -14,11 +14,13 @@ import ( "golang.org/x/xerrors" ) +// RunContainer specifies a runtime container for performing command tests type RunContainer struct { name string ctx context.Context } +// ContainerConfig describes the RunContainer configuration schema for initializing a testing environment type ContainerConfig struct { Name string Image string @@ -40,6 +42,7 @@ func preflightChecks() error { return nil } +// NewRunContainer starts a new docker container for executing command tests func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContainer, error) { if err := preflightChecks(); err != nil { return nil, err @@ -68,6 +71,7 @@ func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContaine }, nil } +// Close kills and removes the command execution testing container func (r *RunContainer) Close() error { cmd := exec.CommandContext(r.ctx, "sh", "-c", strings.Join([]string{ @@ -84,6 +88,7 @@ func (r *RunContainer) Close() error { return nil } +// Assertable describes an initialized command ready to be run and asserted against type Assertable struct { cmd *exec.Cmd ctx context.Context @@ -117,6 +122,7 @@ func (r *RunContainer) RunCmd(cmd *exec.Cmd) *Assertable { } } +// Assert runs the Assertable and func (a Assertable) Assert(t *testing.T, option ...Assertion) { t.Run(strings.Join(a.cmd.Args[6:], " "), func(t *testing.T) { var cmdResult CommandResult @@ -158,14 +164,19 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { }) } +// Assertion specifies an assertion on the given CommandResult. +// Pass custom Assertion types to cover special cases. type Assertion interface { Valid(r CommandResult) error } +// Named is an optional extension of Assertion that provides a helpful label +// to *testing.T type Named interface { Name() string } +// CommandResult contains the aggregated result of a command execution type CommandResult struct { Stdout, Stderr []byte ExitCode int @@ -185,10 +196,12 @@ func (s simpleFuncAssert) Name() string { return s.name } +// Success asserts that the command exited with an exit code of 0 func Success() Assertion { return ExitCodeIs(0) } +// ExitCodeIs asserts that the command exited with the given code func ExitCodeIs(code int) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -201,6 +214,7 @@ func ExitCodeIs(code int) Assertion { } } +// StdoutEmpty asserts that the command did not write any data to Stdout func StdoutEmpty() Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -210,6 +224,7 @@ func StdoutEmpty() Assertion { } } +// StderrEmpty asserts that the command did not write any data to Stderr func StderrEmpty() Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -219,6 +234,7 @@ func StderrEmpty() Assertion { } } +// StdoutMatches asserts that Stdout contains a substring which matches the given regexp func StdoutMatches(pattern string) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -228,6 +244,7 @@ func StdoutMatches(pattern string) Assertion { } } +// StderrMatches asserts that Stderr contains a substring which matches the given regexp func StderrMatches(pattern string) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -237,6 +254,7 @@ func StderrMatches(pattern string) Assertion { } } +// CombinedMatches asserts that either Stdout or Stderr a substring which matches the given regexp func CombinedMatches(pattern string) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -270,6 +288,7 @@ func empty(name string, a []byte) error { return nil } +// DurationLessThan asserts that the command completed in less than the given duration func DurationLessThan(dur time.Duration) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { @@ -282,6 +301,7 @@ func DurationLessThan(dur time.Duration) Assertion { } } +// DurationGreaterThan asserts that the command completed in greater than the given duration func DurationGreaterThan(dur time.Duration) Assertion { return simpleFuncAssert{ valid: func(r CommandResult) error { diff --git a/cmd/coder/sync.go b/cmd/coder/sync.go index e0d62708..601fddd7 100644 --- a/cmd/coder/sync.go +++ b/cmd/coder/sync.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "fmt" "log" "os" "os/exec" @@ -34,7 +33,7 @@ func (cmd *syncCmd) RegisterFlags(fl *pflag.FlagSet) { } // version returns local rsync protocol version as a string. -func (_ *syncCmd) version() string { +func rsyncVersion() string { cmd := exec.Command("rsync", "--version") out, err := cmd.CombinedOutput() if err != nil { @@ -93,13 +92,13 @@ func (cmd *syncCmd) Run(fl *pflag.FlagSet) { Client: entClient, } - localVersion := cmd.version() + localVersion := rsyncVersion() remoteVersion, rsyncErr := s.Version() if rsyncErr != nil { flog.Info("Unable to determine remote rsync version. Proceeding cautiously.") } else if localVersion != remoteVersion { - flog.Fatal(fmt.Sprintf("rsync protocol mismatch. %s.", localVersion, rsyncErr)) + flog.Fatal("rsync protocol mismatch: local = %v, remote = %v", localVersion, rsyncErr) } for err == nil || err == sync.ErrRestartSync { diff --git a/internal/activity/pusher.go b/internal/activity/pusher.go index fb57068d..c2102fc1 100644 --- a/internal/activity/pusher.go +++ b/internal/activity/pusher.go @@ -4,8 +4,9 @@ import ( "time" "cdr.dev/coder-cli/internal/entclient" - "go.coder.com/flog" "golang.org/x/time/rate" + + "go.coder.com/flog" ) const pushInterval = time.Minute @@ -20,6 +21,7 @@ type Pusher struct { rate *rate.Limiter } +// NewPusher instantiates a new instance of Pusher func NewPusher(c *entclient.Client, envID, source string) *Pusher { return &Pusher{ envID: envID, @@ -29,6 +31,7 @@ func NewPusher(c *entclient.Client, envID, source string) *Pusher { } } +// Push pushes activity, abiding by a rate limit func (p *Pusher) Push() { if !p.rate.Allow() { return diff --git a/internal/activity/writer.go b/internal/activity/writer.go index 1e5c4f66..a10c4341 100644 --- a/internal/activity/writer.go +++ b/internal/activity/writer.go @@ -7,11 +7,13 @@ type activityWriter struct { wr io.Writer } +// Write writes to the underlying writer and tracks activity func (w *activityWriter) Write(p []byte) (n int, err error) { w.p.Push() return w.wr.Write(p) } +// Writer wraps the given writer such that all writes trigger an activity push func (p *Pusher) Writer(wr io.Writer) io.Writer { return &activityWriter{p: p, wr: wr} } diff --git a/internal/config/file.go b/internal/config/file.go index de254fc7..bb83e746 100644 --- a/internal/config/file.go +++ b/internal/config/file.go @@ -1,20 +1,25 @@ package config +// File provides convenience methods for interacting with *os.File type File string +// Delete deletes the file func (f File) Delete() error { return rm(string(f)) } +// Write writes the string to the file func (f File) Write(s string) error { return write(string(f), 0600, []byte(s)) } +// Read reads the file to a string func (f File) Read() (string, error) { byt, err := read(string(f)) return string(byt), err } +// Coder CLI configuration files var ( Session File = "session" URL File = "url" diff --git a/internal/entclient/activity.go b/internal/entclient/activity.go index 92c0afed..362d7da0 100644 --- a/internal/entclient/activity.go +++ b/internal/entclient/activity.go @@ -4,6 +4,7 @@ import ( "net/http" ) +// PushActivity pushes CLI activity to Coder func (c Client) PushActivity(source string, envID string) error { res, err := c.request("POST", "/api/metrics/usage/push", map[string]string{ "source": source, diff --git a/internal/entclient/client.go b/internal/entclient/client.go index e0609d13..a306c707 100644 --- a/internal/entclient/client.go +++ b/internal/entclient/client.go @@ -6,6 +6,7 @@ import ( "net/url" ) +// Client wraps the Coder HTTP API type Client struct { BaseURL *url.URL Token string diff --git a/internal/entclient/devurl.go b/internal/entclient/devurl.go index 57925372..9260dcce 100644 --- a/internal/entclient/devurl.go +++ b/internal/entclient/devurl.go @@ -5,11 +5,12 @@ import ( "net/http" ) +// DelDevURL deletes the specified devurl func (c Client) DelDevURL(envID, urlID string) error { reqString := "/api/environments/%s/devurls/%s" - reqUrl := fmt.Sprintf(reqString, envID, urlID) + reqURL := fmt.Sprintf(reqString, envID, urlID) - res, err := c.request("DELETE", reqUrl, map[string]string{ + res, err := c.request("DELETE", reqURL, map[string]string{ "environment_id": envID, "url_id": urlID, }) @@ -24,11 +25,12 @@ func (c Client) DelDevURL(envID, urlID string) error { return nil } +// UpsertDevURL upserts the specified devurl for the authenticated user func (c Client) UpsertDevURL(envID, port, access string) error { reqString := "/api/environments/%s/devurls" - reqUrl := fmt.Sprintf(reqString, envID) + reqURL := fmt.Sprintf(reqString, envID) - res, err := c.request("POST", reqUrl, map[string]string{ + res, err := c.request("POST", reqURL, map[string]string{ "environment_id": envID, "port": port, "access": access, diff --git a/internal/entclient/env.go b/internal/entclient/env.go index 45a7aa0e..11a806c5 100644 --- a/internal/entclient/env.go +++ b/internal/entclient/env.go @@ -7,11 +7,13 @@ import ( "nhooyr.io/websocket" ) +// Environment describes a Coder environment type Environment struct { Name string `json:"name"` ID string `json:"id"` } +// Envs gets the list of environments owned by the authenticated user func (c Client) Envs(user *User, org Org) ([]Environment, error) { var envs []Environment err := c.requestBody( @@ -22,6 +24,8 @@ func (c Client) Envs(user *User, org Org) ([]Environment, error) { return envs, err } +// DialWsep dials an environments command execution interface +// See github.com/cdr/wsep for details func (c Client) DialWsep(ctx context.Context, env Environment) (*websocket.Conn, error) { u := c.copyURL() if c.BaseURL.Scheme == "https" { diff --git a/internal/entclient/me.go b/internal/entclient/me.go index 7c7c66f0..69c3bde5 100644 --- a/internal/entclient/me.go +++ b/internal/entclient/me.go @@ -1,11 +1,13 @@ package entclient +// User describes a Coder user account type User struct { ID string `json:"id"` Email string `json:"email"` Username string `json:"username"` } +// Me gets the details of the authenticated user func (c Client) Me() (*User, error) { var u User err := c.requestBody("GET", "/api/users/me", nil, &u) @@ -15,11 +17,13 @@ func (c Client) Me() (*User, error) { return &u, nil } +// SSHKey describes an SSH keypair type SSHKey struct { PublicKey string `json:"public_key"` PrivateKey string `json:"private_key"` } +// SSHKey gets the current SSH kepair of the authenticated user func (c Client) SSHKey() (*SSHKey, error) { var key SSHKey err := c.requestBody("GET", "/api/users/me/sshkey", nil, &key) diff --git a/internal/entclient/org.go b/internal/entclient/org.go index 24a8307b..6ac9cdc5 100644 --- a/internal/entclient/org.go +++ b/internal/entclient/org.go @@ -1,11 +1,13 @@ package entclient +// Org describes an Organization in Coder type Org struct { ID string `json:"id"` Name string `json:"name"` Members []User `json:"members"` } +// Orgs gets all Organizations func (c Client) Orgs() ([]Org, error) { var os []Org err := c.requestBody("GET", "/api/orgs", nil, &os) diff --git a/internal/loginsrv/server.go b/internal/loginsrv/server.go index b062977a..2f8774c9 100644 --- a/internal/loginsrv/server.go +++ b/internal/loginsrv/server.go @@ -6,6 +6,7 @@ import ( "sync" ) +// Server waits for the login callback to send session token type Server struct { TokenCond *sync.Cond Token string diff --git a/internal/sync/sync.go b/internal/sync/sync.go index b00cbeb1..b58382a1 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -210,6 +210,7 @@ func (s Sync) work(ev timedEvent) { } } +// ErrRestartSync describes a known error case that can be solved by re-starting the command var ErrRestartSync = errors.New("the sync exited because it was overloaded, restart it") // workEventGroup converges a group of events to prevent duplicate work. diff --git a/internal/xterminal/terminal.go b/internal/xterminal/terminal.go index d2838725..ce3c9e3f 100644 --- a/internal/xterminal/terminal.go +++ b/internal/xterminal/terminal.go @@ -41,6 +41,7 @@ func ColorEnabled(fd uintptr) (bool, error) { return terminal.IsTerminal(int(fd)), nil } +// ResizeEvent describes the new terminal dimensions following a resize type ResizeEvent struct { Height, Width uint16 } From 5c118b8b0ab908ecdecab3f2043c4e54e127df77 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 15:29:53 -0500 Subject: [PATCH 09/19] Limit github action runs --- .github/workflows/build.yaml | 1 - .github/workflows/integration.yaml | 7 +++++-- .github/workflows/test.yaml | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 11dec656..50cdd7d9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,5 +1,4 @@ name: build - on: [push] jobs: diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index cff8699a..ae0121a4 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -1,5 +1,8 @@ -name: ci -on: [push, pull_request] +name: integration +on: + push: + schedule: + - cron: '*/180 * * * *' jobs: integration: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fb7e32db..36397b7d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,5 +1,5 @@ -name: ci -on: [push, pull_request] +name: test +on: [push] jobs: fmt: From 50c4e75b394447f654e74bfbc8789d6f0e947934 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 15:39:30 -0500 Subject: [PATCH 10/19] Add GetResult Assertion --- ci/tcli/tcli.go | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 574e3bf8..79d26580 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -157,7 +157,7 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { } t.Run(name, func(t *testing.T) { t.Parallel() - err := o.Valid(cmdResult) + err := o.Valid(&cmdResult) assert.Success(t, name, err) }) } @@ -167,7 +167,7 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { // Assertion specifies an assertion on the given CommandResult. // Pass custom Assertion types to cover special cases. type Assertion interface { - Valid(r CommandResult) error + Valid(r *CommandResult) error } // Named is an optional extension of Assertion that provides a helpful label @@ -184,11 +184,11 @@ type CommandResult struct { } type simpleFuncAssert struct { - valid func(r CommandResult) error + valid func(r *CommandResult) error name string } -func (s simpleFuncAssert) Valid(r CommandResult) error { +func (s simpleFuncAssert) Valid(r *CommandResult) error { return s.valid(r) } @@ -204,7 +204,7 @@ func Success() Assertion { // ExitCodeIs asserts that the command exited with the given code func ExitCodeIs(code int) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { if r.ExitCode != code { return xerrors.Errorf("exit code of %v expected, got %v", code, r.ExitCode) } @@ -217,17 +217,29 @@ func ExitCodeIs(code int) Assertion { // StdoutEmpty asserts that the command did not write any data to Stdout func StdoutEmpty() Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { return empty("stdout", r.Stdout) }, name: fmt.Sprintf("stdout-empty"), } } +// GetResult offers an escape hatch from tcli +// The passed pointer will be assigned to the commands *CommandResult +func GetResult(result **CommandResult) Assertion { + return simpleFuncAssert{ + valid: func(r *CommandResult) error { + *result = r + return nil + }, + name: "get-stdout", + } +} + // StderrEmpty asserts that the command did not write any data to Stderr func StderrEmpty() Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { return empty("stderr", r.Stderr) }, name: fmt.Sprintf("stderr-empty"), @@ -237,7 +249,7 @@ func StderrEmpty() Assertion { // StdoutMatches asserts that Stdout contains a substring which matches the given regexp func StdoutMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { return matches("stdout", pattern, r.Stdout) }, name: fmt.Sprintf("stdout-matches"), @@ -247,7 +259,7 @@ func StdoutMatches(pattern string) Assertion { // StderrMatches asserts that Stderr contains a substring which matches the given regexp func StderrMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { return matches("stderr", pattern, r.Stderr) }, name: fmt.Sprintf("stderr-matches"), @@ -257,7 +269,7 @@ func StderrMatches(pattern string) Assertion { // CombinedMatches asserts that either Stdout or Stderr a substring which matches the given regexp func CombinedMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { //stdoutValid := StdoutMatches(pattern).Valid(r) //stderrValid := StderrMatches(pattern).Valid(r) // TODO: combine errors @@ -291,7 +303,7 @@ func empty(name string, a []byte) error { // DurationLessThan asserts that the command completed in less than the given duration func DurationLessThan(dur time.Duration) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { if r.Duration > dur { return xerrors.Errorf("expected duration less than %s, took %s", dur.String(), r.Duration.String()) } @@ -304,7 +316,7 @@ func DurationLessThan(dur time.Duration) Assertion { // DurationGreaterThan asserts that the command completed in greater than the given duration func DurationGreaterThan(dur time.Duration) Assertion { return simpleFuncAssert{ - valid: func(r CommandResult) error { + valid: func(r *CommandResult) error { if r.Duration < dur { return xerrors.Errorf("expected duration greater than %s, took %s", dur.String(), r.Duration.String()) } From bf3166656651cdde89f2aee5a1a06b480fa6e139 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 17:44:30 -0500 Subject: [PATCH 11/19] Add host runner --- ci/integration/integration_test.go | 4 +- ci/tcli/tcli.go | 85 ++++++++++++++++++++++-------- 2 files changed, 65 insertions(+), 24 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 9eb1d8ef..5fb9f0ac 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -46,7 +46,7 @@ func init() { func TestTCli(t *testing.T) { ctx := context.Background() - container, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ + container, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ Image: "ubuntu:latest", Name: "test-container", BindMounts: map[string]string{ @@ -94,7 +94,7 @@ func TestTCli(t *testing.T) { func TestCoderCLI(t *testing.T) { ctx := context.Background() - c, err := tcli.NewRunContainer(ctx, &tcli.ContainerConfig{ + c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ Image: "ubuntu:latest", Name: "test-container", BindMounts: map[string]string{ diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 79d26580..69460c43 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "io" "os/exec" "regexp" "strings" @@ -14,13 +15,18 @@ import ( "golang.org/x/xerrors" ) -// RunContainer specifies a runtime container for performing command tests -type RunContainer struct { - name string - ctx context.Context +var ( + _ runnable = &ContainerRunner{} + _ runnable = &HostRunner{} +) + +type runnable interface { + Run(ctx context.Context, command string) *Assertable + RunCmd(cmd *exec.Cmd) *Assertable + io.Closer } -// ContainerConfig describes the RunContainer configuration schema for initializing a testing environment +// ContainerConfig describes the ContainerRunner configuration schema for initializing a testing environment type ContainerConfig struct { Name string Image string @@ -42,8 +48,14 @@ func preflightChecks() error { return nil } -// NewRunContainer starts a new docker container for executing command tests -func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContainer, error) { +// ContainerRunner specifies a runtime container for performing command tests +type ContainerRunner struct { + name string + ctx context.Context +} + +// NewContainerRunner starts a new docker container for executing command tests +func NewContainerRunner(ctx context.Context, config *ContainerConfig) (*ContainerRunner, error) { if err := preflightChecks(); err != nil { return nil, err } @@ -65,14 +77,14 @@ func NewRunContainer(ctx context.Context, config *ContainerConfig) (*RunContaine config.Name, string(out), err) } - return &RunContainer{ + return &ContainerRunner{ name: config.Name, ctx: ctx, }, nil } // Close kills and removes the command execution testing container -func (r *RunContainer) Close() error { +func (r *ContainerRunner) Close() error { cmd := exec.CommandContext(r.ctx, "sh", "-c", strings.Join([]string{ "docker", "kill", r.name, "&&", @@ -88,43 +100,72 @@ func (r *RunContainer) Close() error { return nil } +type HostRunner struct{} + +func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { + var ( + args []string + path string + parts = strings.Split(command, " ") + ) + if len(parts) > 0 { + path = parts[0] + } + if len(parts) > 1 { + args = parts[1:] + } + return &Assertable{ + cmd: exec.CommandContext(ctx, path, args...), + tname: command, + } +} + +func (r *HostRunner) RunCmd(cmd *exec.Cmd) *Assertable { + return &Assertable{ + cmd: cmd, + tname: strings.Join(cmd.Args, " "), + } +} + +func (r *HostRunner) Close() error { + return nil +} + // Assertable describes an initialized command ready to be run and asserted against type Assertable struct { - cmd *exec.Cmd - ctx context.Context - container *RunContainer + cmd *exec.Cmd + tname string } // Run executes the given command in the runtime container with reasonable defaults -func (r *RunContainer) Run(ctx context.Context, command string) *Assertable { +func (r *ContainerRunner) Run(ctx context.Context, command string) *Assertable { cmd := exec.CommandContext(ctx, "docker", "exec", "-i", r.name, "sh", "-c", command, ) return &Assertable{ - cmd: cmd, - ctx: ctx, - container: r, + cmd: cmd, + tname: command, } } // RunCmd lifts the given *exec.Cmd into the runtime container -func (r *RunContainer) RunCmd(cmd *exec.Cmd) *Assertable { +func (r *ContainerRunner) RunCmd(cmd *exec.Cmd) *Assertable { path, _ := exec.LookPath("docker") cmd.Path = path command := strings.Join(cmd.Args, " ") - cmd.Args = append([]string{"docker", "exec", "-i", r.name, "sh", "-c", command}) + cmd.Args = []string{"docker", "exec", "-i", r.name, "sh", "-c", command} return &Assertable{ - cmd: cmd, - container: r, + cmd: cmd, + tname: command, } } // Assert runs the Assertable and func (a Assertable) Assert(t *testing.T, option ...Assertion) { - t.Run(strings.Join(a.cmd.Args[6:], " "), func(t *testing.T) { + t.Run(a.tname, func(t *testing.T) { var cmdResult CommandResult var ( @@ -225,7 +266,7 @@ func StdoutEmpty() Assertion { } // GetResult offers an escape hatch from tcli -// The passed pointer will be assigned to the commands *CommandResult +// The pointer passed as "result" will be assigned to the command's *CommandResult func GetResult(result **CommandResult) Assertion { return simpleFuncAssert{ valid: func(r *CommandResult) error { From f4fe567b28e03adc7a0ce932634d090ec0ee61cd Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 17:59:04 -0500 Subject: [PATCH 12/19] Add tests for host runner --- ci/integration/integration_test.go | 22 ++++++++++++++++++++++ ci/tcli/tcli.go | 8 +++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 5fb9f0ac..82be3697 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -91,6 +91,28 @@ func TestTCli(t *testing.T) { ) } +func TestHostRunner(t *testing.T) { + var ( + c tcli.HostRunner + ctx = context.Background() + ) + + c.Run(ctx, "echo testing").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("testing"), + ) + + wd, err := os.Getwd() + assert.Success(t, "get working dir", err) + + c.Run(ctx, "pwd").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches(wd), + ) +} + func TestCoderCLI(t *testing.T) { ctx := context.Background() diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 69460c43..f9c012be 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -100,8 +100,10 @@ func (r *ContainerRunner) Close() error { return nil } +// HostRunner executes command tests on the host, outside of a container type HostRunner struct{} +// Run executes the given command on the host func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { var ( args []string @@ -114,12 +116,15 @@ func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { if len(parts) > 1 { args = parts[1:] } + cmd := exec.CommandContext(ctx, path, args...) + return &Assertable{ - cmd: exec.CommandContext(ctx, path, args...), + cmd: cmd, tname: command, } } +// RunCmd executes the given command on the host func (r *HostRunner) RunCmd(cmd *exec.Cmd) *Assertable { return &Assertable{ cmd: cmd, @@ -127,6 +132,7 @@ func (r *HostRunner) RunCmd(cmd *exec.Cmd) *Assertable { } } +// Close is a noop for HostRunner func (r *HostRunner) Close() error { return nil } From 9d358a60b23a5906cf2b7faa8ba6c6506cb615e7 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 19:53:54 -0500 Subject: [PATCH 13/19] Adds headless login workaround --- .gitignore | 1 + ci/integration/integration_test.go | 39 ++++++++++++++-- ci/integration/login_test.go | 75 ++++++++++++++++++++++++++++++ ci/tcli/doc.go | 4 ++ ci/tcli/tcli.go | 18 +++++-- 5 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 ci/integration/login_test.go create mode 100644 ci/tcli/doc.go diff --git a/.gitignore b/.gitignore index 05e4aa48..c3d7f6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ ci/bin cmd/coder/coder ci/integration/bin +ci/integration/env.sh \ No newline at end of file diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 82be3697..0fb81a79 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -114,11 +114,12 @@ func TestHostRunner(t *testing.T) { } func TestCoderCLI(t *testing.T) { - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) + defer cancel() c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ - Image: "ubuntu:latest", - Name: "test-container", + Image: "codercom/enterprise-dev", + Name: "coder-cli-tests", BindMounts: map[string]string{ binpath: "/bin/coder", }, @@ -138,4 +139,36 @@ func TestCoderCLI(t *testing.T) { tcli.StderrMatches("Usage: coder"), tcli.StdoutEmpty(), ) + + creds := login(ctx, t) + c.Run(ctx, fmt.Sprintf("mkdir -p ~/.config/coder && echo -ne %s > ~/.config/coder/session", creds.token)).Assert(t, + tcli.Success(), + ) + c.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, + tcli.Success(), + ) + + c.Run(ctx, "coder envs").Assert(t, + tcli.Success(), + ) + + c.Run(ctx, "coder urls").Assert(t, + tcli.Error(), + ) + + c.Run(ctx, "coder sync").Assert(t, + tcli.Error(), + ) + + c.Run(ctx, "coder sh").Assert(t, + tcli.Error(), + ) + + c.Run(ctx, "coder logout").Assert(t, + tcli.Success(), + ) + + c.Run(ctx, "coder envs").Assert(t, + tcli.Error(), + ) } diff --git a/ci/integration/login_test.go b/ci/integration/login_test.go new file mode 100644 index 00000000..e0334f00 --- /dev/null +++ b/ci/integration/login_test.go @@ -0,0 +1,75 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "testing" + + "cdr.dev/slog/sloggers/slogtest/assert" +) + +type credentials struct { + url, token string +} + +func login(ctx context.Context, t *testing.T) credentials { + var ( + email = requireEnv(t, "CODER_EMAIL") + password = requireEnv(t, "CODER_PASSWORD") + rawURL = requireEnv(t, "CODER_URL") + ) + sessionToken := getSessionToken(ctx, t, email, password, rawURL) + + return credentials{ + url: rawURL, + token: sessionToken, + } +} + +func requireEnv(t *testing.T, key string) string { + value := os.Getenv(key) + assert.True(t, fmt.Sprintf("%q is nonempty", key), value != "") + return value +} + +type loginBuiltInAuthReq struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type loginBuiltInAuthResp struct { + SessionToken string `json:"session_token"` +} + +func getSessionToken(ctx context.Context, t *testing.T, email, password, rawURL string) string { + reqbody := loginBuiltInAuthReq{ + Email: email, + Password: password, + } + body, err := json.Marshal(reqbody) + assert.Success(t, "marshal login req body", err) + + u, err := url.Parse(rawURL) + assert.Success(t, "parse raw url", err) + u.Path = "/auth/basic/login" + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), bytes.NewReader(body)) + assert.Success(t, "new request", err) + + resp, err := http.DefaultClient.Do(req) + assert.Success(t, "do request", err) + assert.Equal(t, "request status 201", http.StatusCreated, resp.StatusCode) + + var tokenResp loginBuiltInAuthResp + err = json.NewDecoder(resp.Body).Decode(&tokenResp) + assert.Success(t, "decode response", err) + + defer resp.Body.Close() + + return tokenResp.SessionToken +} diff --git a/ci/tcli/doc.go b/ci/tcli/doc.go new file mode 100644 index 00000000..f82d3df3 --- /dev/null +++ b/ci/tcli/doc.go @@ -0,0 +1,4 @@ +// Package tcli provides a framework for CLI integration testing. +// Execute commands on the raw host of inside docker container. +// Define custom Assertion types to extend test functionality. +package tcli diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index f9c012be..69d30227 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -203,7 +203,6 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { name = named.Name() } t.Run(name, func(t *testing.T) { - t.Parallel() err := o.Valid(&cmdResult) assert.Success(t, name, err) }) @@ -248,12 +247,25 @@ func Success() Assertion { return ExitCodeIs(0) } +// Error asserts that the command exited with a nonzero exit code +func Error() Assertion { + return simpleFuncAssert{ + valid: func(r *CommandResult) error { + if r.ExitCode == 0 { + return xerrors.Errorf("expected nonzero exit code, got %v", r.ExitCode) + } + return nil + }, + name: fmt.Sprintf("error"), + } +} + // ExitCodeIs asserts that the command exited with the given code func ExitCodeIs(code int) Assertion { return simpleFuncAssert{ valid: func(r *CommandResult) error { if r.ExitCode != code { - return xerrors.Errorf("exit code of %v expected, got %v", code, r.ExitCode) + return xerrors.Errorf("exit code of %v expected, got %v, (%s)", code, r.ExitCode, string(r.Stderr)) } return nil }, @@ -279,7 +291,7 @@ func GetResult(result **CommandResult) Assertion { *result = r return nil }, - name: "get-stdout", + name: "get-result", } } From e6c79f5ca755d2c79cae81716f5b05bea9b022ca Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 20:16:23 -0500 Subject: [PATCH 14/19] Add verbose logging to integration tests --- .github/workflows/integration.yaml | 2 +- ci/integration/integration_test.go | 9 ++++++++- ci/tcli/tcli.go | 10 ++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index ae0121a4..a8045e43 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -23,4 +23,4 @@ jobs: with: go-version: '^1.14' - name: go test - run: go test ./ci/integration/... + run: go test -v ./ci/integration/... diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 0fb81a79..8bc3f7ce 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -44,6 +44,7 @@ func init() { } func TestTCli(t *testing.T) { + t.Parallel() ctx := context.Background() container, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ @@ -92,6 +93,7 @@ func TestTCli(t *testing.T) { } func TestHostRunner(t *testing.T) { + t.Parallel() var ( c tcli.HostRunner ctx = context.Background() @@ -114,6 +116,7 @@ func TestHostRunner(t *testing.T) { } func TestCoderCLI(t *testing.T) { + t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) defer cancel() @@ -141,7 +144,11 @@ func TestCoderCLI(t *testing.T) { ) creds := login(ctx, t) - c.Run(ctx, fmt.Sprintf("mkdir -p ~/.config/coder && echo -ne %s > ~/.config/coder/session", creds.token)).Assert(t, + cmd := exec.CommandContext(ctx, "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") + + // !IMPORTANT: be careful that this does not appear in logs + cmd.Stdin = strings.NewReader(creds.token) + c.RunCmd(cmd).Assert(t, tcli.Success(), ) c.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 69d30227..ea66fce3 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "cdr.dev/slog/sloggers/slogtest/assert" "golang.org/x/xerrors" ) @@ -197,6 +199,14 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { cmdResult.Stdout = stdout.Bytes() cmdResult.Stderr = stderr.Bytes() + slogtest.Info(t, "command output", + slog.F("command", a.cmd), + slog.F("stdout", string(cmdResult.Stdout)), + slog.F("stderr", string(cmdResult.Stderr)), + slog.F("exit-code", cmdResult.ExitCode), + slog.F("duration", cmdResult.Duration), + ) + for ix, o := range option { name := fmt.Sprintf("assertion_#%v", ix) if named, ok := o.(Named); ok { From b5b93d49fa2bfc3a90d7e7559619150ab1523445 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 21:20:31 -0500 Subject: [PATCH 15/19] Fix tcli log helpers --- ci/tcli/tcli.go | 178 +++++++++++++++++++++++++----------------------- 1 file changed, 92 insertions(+), 86 deletions(-) diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index ea66fce3..34141f29 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -173,57 +173,49 @@ func (r *ContainerRunner) RunCmd(cmd *exec.Cmd) *Assertable { // Assert runs the Assertable and func (a Assertable) Assert(t *testing.T, option ...Assertion) { - t.Run(a.tname, func(t *testing.T) { - var cmdResult CommandResult + slog.Helper() + var cmdResult CommandResult - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) - a.cmd.Stdout = &stdout - a.cmd.Stderr = &stderr - - start := time.Now() - err := a.cmd.Run() - cmdResult.Duration = time.Since(start) - - if exitErr, ok := err.(*exec.ExitError); ok { - cmdResult.ExitCode = exitErr.ExitCode() - } else if err != nil { - cmdResult.ExitCode = -1 - } else { - cmdResult.ExitCode = 0 - } - - cmdResult.Stdout = stdout.Bytes() - cmdResult.Stderr = stderr.Bytes() - - slogtest.Info(t, "command output", - slog.F("command", a.cmd), - slog.F("stdout", string(cmdResult.Stdout)), - slog.F("stderr", string(cmdResult.Stderr)), - slog.F("exit-code", cmdResult.ExitCode), - slog.F("duration", cmdResult.Duration), - ) + a.cmd.Stdout = &stdout + a.cmd.Stderr = &stderr - for ix, o := range option { - name := fmt.Sprintf("assertion_#%v", ix) - if named, ok := o.(Named); ok { - name = named.Name() - } - t.Run(name, func(t *testing.T) { - err := o.Valid(&cmdResult) - assert.Success(t, name, err) - }) - } - }) + start := time.Now() + err := a.cmd.Run() + cmdResult.Duration = time.Since(start) + + if exitErr, ok := err.(*exec.ExitError); ok { + cmdResult.ExitCode = exitErr.ExitCode() + } else if err != nil { + cmdResult.ExitCode = -1 + } else { + cmdResult.ExitCode = 0 + } + + cmdResult.Stdout = stdout.Bytes() + cmdResult.Stderr = stderr.Bytes() + + slogtest.Info(t, "command output", + slog.F("command", a.cmd), + slog.F("stdout", string(cmdResult.Stdout)), + slog.F("stderr", string(cmdResult.Stderr)), + slog.F("exit-code", cmdResult.ExitCode), + slog.F("duration", cmdResult.Duration), + ) + + for _, o := range option { + o.Valid(t, &cmdResult) + } } // Assertion specifies an assertion on the given CommandResult. // Pass custom Assertion types to cover special cases. type Assertion interface { - Valid(r *CommandResult) error + Valid(t *testing.T, r *CommandResult) } // Named is an optional extension of Assertion that provides a helpful label @@ -240,12 +232,13 @@ type CommandResult struct { } type simpleFuncAssert struct { - valid func(r *CommandResult) error + valid func(t *testing.T, r *CommandResult) name string } -func (s simpleFuncAssert) Valid(r *CommandResult) error { - return s.valid(r) +func (s simpleFuncAssert) Valid(t *testing.T, r *CommandResult) { + slog.Helper() + s.valid(t, r) } func (s simpleFuncAssert) Name() string { @@ -254,17 +247,16 @@ func (s simpleFuncAssert) Name() string { // Success asserts that the command exited with an exit code of 0 func Success() Assertion { + slog.Helper() return ExitCodeIs(0) } // Error asserts that the command exited with a nonzero exit code func Error() Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - if r.ExitCode == 0 { - return xerrors.Errorf("expected nonzero exit code, got %v", r.ExitCode) - } - return nil + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + assert.True(t, "exit code is nonzero", r.ExitCode != 0) }, name: fmt.Sprintf("error"), } @@ -273,11 +265,9 @@ func Error() Assertion { // ExitCodeIs asserts that the command exited with the given code func ExitCodeIs(code int) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - if r.ExitCode != code { - return xerrors.Errorf("exit code of %v expected, got %v, (%s)", code, r.ExitCode, string(r.Stderr)) - } - return nil + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + assert.Equal(t, "exit code is as expected", code, r.ExitCode) }, name: fmt.Sprintf("exitcode"), } @@ -286,8 +276,9 @@ func ExitCodeIs(code int) Assertion { // StdoutEmpty asserts that the command did not write any data to Stdout func StdoutEmpty() Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - return empty("stdout", r.Stdout) + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + empty(t, "stdout", r.Stdout) }, name: fmt.Sprintf("stdout-empty"), } @@ -297,9 +288,10 @@ func StdoutEmpty() Assertion { // The pointer passed as "result" will be assigned to the command's *CommandResult func GetResult(result **CommandResult) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + empty(t, "stdout", r.Stdout) *result = r - return nil }, name: "get-result", } @@ -308,8 +300,9 @@ func GetResult(result **CommandResult) Assertion { // StderrEmpty asserts that the command did not write any data to Stderr func StderrEmpty() Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - return empty("stderr", r.Stderr) + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + empty(t, "stderr", r.Stderr) }, name: fmt.Sprintf("stderr-empty"), } @@ -318,8 +311,9 @@ func StderrEmpty() Assertion { // StdoutMatches asserts that Stdout contains a substring which matches the given regexp func StdoutMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - return matches("stdout", pattern, r.Stdout) + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + matches(t, "stdout", pattern, r.Stdout) }, name: fmt.Sprintf("stdout-matches"), } @@ -328,8 +322,9 @@ func StdoutMatches(pattern string) Assertion { // StderrMatches asserts that Stderr contains a substring which matches the given regexp func StderrMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - return matches("stderr", pattern, r.Stderr) + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + matches(t, "stderr", pattern, r.Stderr) }, name: fmt.Sprintf("stderr-matches"), } @@ -338,45 +333,53 @@ func StderrMatches(pattern string) Assertion { // CombinedMatches asserts that either Stdout or Stderr a substring which matches the given regexp func CombinedMatches(pattern string) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { - //stdoutValid := StdoutMatches(pattern).Valid(r) - //stderrValid := StderrMatches(pattern).Valid(r) - // TODO: combine errors - return nil + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() + StdoutMatches(pattern).Valid(t, r) + StderrMatches(pattern).Valid(t, r) }, name: fmt.Sprintf("combined-matches"), } } -func matches(name, pattern string, target []byte) error { +func matches(t *testing.T, name, pattern string, target []byte) { + slog.Helper() + ok, err := regexp.Match(pattern, target) if err != nil { - return xerrors.Errorf("failed to attempt regexp match: %w", err) + slogtest.Fatal(t, "failed to attempt regexp match", slog.Error(err), + slog.F("pattern", pattern), + slog.F("target", string(target)), + slog.F("sink", name), + ) } if !ok { - return xerrors.Errorf( - "expected to find pattern (%s) in %s, no match found in (%v)", - pattern, name, string(target), + slogtest.Fatal(t, "expected to find pattern, no match found", + slog.F("pattern", pattern), + slog.F("target", string(target)), + slog.F("sink", name), ) } - return nil } -func empty(name string, a []byte) error { +func empty(t *testing.T, name string, a []byte) { + slog.Helper() if len(a) > 0 { - return xerrors.Errorf("expected %s to be empty, got (%s)", name, string(a)) + slogtest.Fatal(t, "expected "+name+" to be empty", slog.F("got", string(a))) } - return nil } // DurationLessThan asserts that the command completed in less than the given duration func DurationLessThan(dur time.Duration) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() if r.Duration > dur { - return xerrors.Errorf("expected duration less than %s, took %s", dur.String(), r.Duration.String()) + slogtest.Fatal(t, "duration longer than expected", + slog.F("expected_less_than", dur.String), + slog.F("actual", r.Duration.String()), + ) } - return nil }, name: fmt.Sprintf("duration-lessthan"), } @@ -385,11 +388,14 @@ func DurationLessThan(dur time.Duration) Assertion { // DurationGreaterThan asserts that the command completed in greater than the given duration func DurationGreaterThan(dur time.Duration) Assertion { return simpleFuncAssert{ - valid: func(r *CommandResult) error { + valid: func(t *testing.T, r *CommandResult) { + slog.Helper() if r.Duration < dur { - return xerrors.Errorf("expected duration greater than %s, took %s", dur.String(), r.Duration.String()) + slogtest.Fatal(t, "duration shorter than expected", + slog.F("expected_greater_than", dur.String), + slog.F("actual", r.Duration.String()), + ) } - return nil }, name: fmt.Sprintf("duration-greaterthan"), } From c451d16190dcccaff07ffc45a4bc0429b6a49753 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 21:41:15 -0500 Subject: [PATCH 16/19] Move tcli tests to tcli package --- ci/integration/integration_test.go | 78 +++--------------------------- ci/tcli/tcli_test.go | 69 ++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 72 deletions(-) create mode 100644 ci/tcli/tcli_test.go diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 8bc3f7ce..23e2b5c8 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -43,78 +43,6 @@ func init() { } } -func TestTCli(t *testing.T) { - t.Parallel() - ctx := context.Background() - - container, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ - Image: "ubuntu:latest", - Name: "test-container", - BindMounts: map[string]string{ - binpath: "/bin/coder", - }, - }) - assert.Success(t, "new run container", err) - defer container.Close() - - container.Run(ctx, "echo testing").Assert(t, - tcli.Success(), - tcli.StderrEmpty(), - tcli.StdoutMatches("esting"), - ) - - container.Run(ctx, "sleep 1.5 && echo 1>&2 stderr-message").Assert(t, - tcli.Success(), - tcli.StdoutEmpty(), - tcli.StderrMatches("message"), - tcli.DurationGreaterThan(time.Second), - ) - - cmd := exec.CommandContext(ctx, "cat") - cmd.Stdin = strings.NewReader("testing") - - container.RunCmd(cmd).Assert(t, - tcli.Success(), - tcli.StderrEmpty(), - tcli.StdoutMatches("testing"), - ) - - container.Run(ctx, "which coder").Assert(t, - tcli.Success(), - tcli.StdoutMatches("/bin/coder"), - tcli.StderrEmpty(), - ) - - container.Run(ctx, "coder version").Assert(t, - tcli.StderrEmpty(), - tcli.Success(), - tcli.StdoutMatches("linux"), - ) -} - -func TestHostRunner(t *testing.T) { - t.Parallel() - var ( - c tcli.HostRunner - ctx = context.Background() - ) - - c.Run(ctx, "echo testing").Assert(t, - tcli.Success(), - tcli.StderrEmpty(), - tcli.StdoutMatches("testing"), - ) - - wd, err := os.Getwd() - assert.Success(t, "get working dir", err) - - c.Run(ctx, "pwd").Assert(t, - tcli.Success(), - tcli.StderrEmpty(), - tcli.StdoutMatches(wd), - ) -} - func TestCoderCLI(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) @@ -130,6 +58,12 @@ func TestCoderCLI(t *testing.T) { assert.Success(t, "new run container", err) defer c.Close() + c.Run(ctx, "which coder").Assert(t, + tcli.Success(), + tcli.StdoutMatches("/usr/sbin/coder"), + tcli.StderrEmpty(), + ) + c.Run(ctx, "coder version").Assert(t, tcli.StderrEmpty(), tcli.Success(), diff --git a/ci/tcli/tcli_test.go b/ci/tcli/tcli_test.go new file mode 100644 index 00000000..97bd1b6e --- /dev/null +++ b/ci/tcli/tcli_test.go @@ -0,0 +1,69 @@ +package tcli_test + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +func TestTCli(t *testing.T) { + t.Parallel() + ctx := context.Background() + + container, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ + Image: "ubuntu:latest", + Name: "test-container", + }) + assert.Success(t, "new run container", err) + defer container.Close() + + container.Run(ctx, "echo testing").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("esting"), + ) + + container.Run(ctx, "sleep 1.5 && echo 1>&2 stderr-message").Assert(t, + tcli.Success(), + tcli.StdoutEmpty(), + tcli.StderrMatches("message"), + tcli.DurationGreaterThan(time.Second), + ) + + cmd := exec.CommandContext(ctx, "cat") + cmd.Stdin = strings.NewReader("testing") + + container.RunCmd(cmd).Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("testing"), + ) +} +func TestHostRunner(t *testing.T) { + t.Parallel() + var ( + c tcli.HostRunner + ctx = context.Background() + ) + + c.Run(ctx, "echo testing").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches("testing"), + ) + + wd, err := os.Getwd() + assert.Success(t, "get working dir", err) + + c.Run(ctx, "pwd").Assert(t, + tcli.Success(), + tcli.StderrEmpty(), + tcli.StdoutMatches(wd), + ) +} From cafdd6f7eb78f4ba4ce2690b6fc1ffd2c603bcd5 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Tue, 28 Jul 2020 21:58:10 -0500 Subject: [PATCH 17/19] Simplify assertion type --- ci/integration/integration_test.go | 27 +++-- ci/tcli/tcli.go | 185 ++++++++++------------------- 2 files changed, 76 insertions(+), 136 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 23e2b5c8..3fb031be 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -43,6 +43,21 @@ func init() { } } +// write session tokens to the given container runner +func headlessLogin(ctx context.Context, t *testing.T, runner *tcli.ContainerRunner) { + creds := login(ctx, t) + cmd := exec.CommandContext(ctx, "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") + + // !IMPORTANT: be careful that this does not appear in logs + cmd.Stdin = strings.NewReader(creds.token) + runner.RunCmd(cmd).Assert(t, + tcli.Success(), + ) + runner.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, + tcli.Success(), + ) +} + func TestCoderCLI(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5) @@ -77,17 +92,7 @@ func TestCoderCLI(t *testing.T) { tcli.StdoutEmpty(), ) - creds := login(ctx, t) - cmd := exec.CommandContext(ctx, "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") - - // !IMPORTANT: be careful that this does not appear in logs - cmd.Stdin = strings.NewReader(creds.token) - c.RunCmd(cmd).Assert(t, - tcli.Success(), - ) - c.Run(ctx, fmt.Sprintf("echo -ne %s > ~/.config/coder/url", creds.url)).Assert(t, - tcli.Success(), - ) + headlessLogin(ctx, t, c) c.Run(ctx, "coder envs").Assert(t, tcli.Success(), diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 34141f29..de9fab86 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -174,11 +174,10 @@ func (r *ContainerRunner) RunCmd(cmd *exec.Cmd) *Assertable { // Assert runs the Assertable and func (a Assertable) Assert(t *testing.T, option ...Assertion) { slog.Helper() - var cmdResult CommandResult - var ( stdout bytes.Buffer stderr bytes.Buffer + result CommandResult ) a.cmd.Stdout = &stdout @@ -186,43 +185,36 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { start := time.Now() err := a.cmd.Run() - cmdResult.Duration = time.Since(start) + result.Duration = time.Since(start) if exitErr, ok := err.(*exec.ExitError); ok { - cmdResult.ExitCode = exitErr.ExitCode() + result.ExitCode = exitErr.ExitCode() } else if err != nil { - cmdResult.ExitCode = -1 + // TODO: handle this case better + result.ExitCode = -1 } else { - cmdResult.ExitCode = 0 + result.ExitCode = 0 } - cmdResult.Stdout = stdout.Bytes() - cmdResult.Stderr = stderr.Bytes() + result.Stdout = stdout.Bytes() + result.Stderr = stderr.Bytes() slogtest.Info(t, "command output", slog.F("command", a.cmd), - slog.F("stdout", string(cmdResult.Stdout)), - slog.F("stderr", string(cmdResult.Stderr)), - slog.F("exit-code", cmdResult.ExitCode), - slog.F("duration", cmdResult.Duration), + slog.F("stdout", string(result.Stdout)), + slog.F("stderr", string(result.Stderr)), + slog.F("exit_code", result.ExitCode), + slog.F("duration", result.Duration), ) - for _, o := range option { - o.Valid(t, &cmdResult) + for _, assertion := range option { + assertion(t, &result) } } // Assertion specifies an assertion on the given CommandResult. -// Pass custom Assertion types to cover special cases. -type Assertion interface { - Valid(t *testing.T, r *CommandResult) -} - -// Named is an optional extension of Assertion that provides a helpful label -// to *testing.T -type Named interface { - Name() string -} +// Pass custom Assertion functions to cover special cases. +type Assertion func(t *testing.T, r *CommandResult) // CommandResult contains the aggregated result of a command execution type CommandResult struct { @@ -231,20 +223,6 @@ type CommandResult struct { Duration time.Duration } -type simpleFuncAssert struct { - valid func(t *testing.T, r *CommandResult) - name string -} - -func (s simpleFuncAssert) Valid(t *testing.T, r *CommandResult) { - slog.Helper() - s.valid(t, r) -} - -func (s simpleFuncAssert) Name() string { - return s.name -} - // Success asserts that the command exited with an exit code of 0 func Success() Assertion { slog.Helper() @@ -253,112 +231,75 @@ func Success() Assertion { // Error asserts that the command exited with a nonzero exit code func Error() Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - assert.True(t, "exit code is nonzero", r.ExitCode != 0) - }, - name: fmt.Sprintf("error"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + assert.True(t, "exit code is nonzero", r.ExitCode != 0) } } // ExitCodeIs asserts that the command exited with the given code func ExitCodeIs(code int) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - assert.Equal(t, "exit code is as expected", code, r.ExitCode) - }, - name: fmt.Sprintf("exitcode"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + assert.Equal(t, "exit code is as expected", code, r.ExitCode) } } // StdoutEmpty asserts that the command did not write any data to Stdout func StdoutEmpty() Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - empty(t, "stdout", r.Stdout) - }, - name: fmt.Sprintf("stdout-empty"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + empty(t, "stdout", r.Stdout) } } // GetResult offers an escape hatch from tcli // The pointer passed as "result" will be assigned to the command's *CommandResult func GetResult(result **CommandResult) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - empty(t, "stdout", r.Stdout) - *result = r - }, - name: "get-result", + return func(t *testing.T, r *CommandResult) { + slog.Helper() + *result = r } } // StderrEmpty asserts that the command did not write any data to Stderr func StderrEmpty() Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - empty(t, "stderr", r.Stderr) - }, - name: fmt.Sprintf("stderr-empty"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + empty(t, "stderr", r.Stderr) } } // StdoutMatches asserts that Stdout contains a substring which matches the given regexp func StdoutMatches(pattern string) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - matches(t, "stdout", pattern, r.Stdout) - }, - name: fmt.Sprintf("stdout-matches"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + matches(t, "stdout", pattern, r.Stdout) } } // StderrMatches asserts that Stderr contains a substring which matches the given regexp func StderrMatches(pattern string) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - matches(t, "stderr", pattern, r.Stderr) - }, - name: fmt.Sprintf("stderr-matches"), - } -} - -// CombinedMatches asserts that either Stdout or Stderr a substring which matches the given regexp -func CombinedMatches(pattern string) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - StdoutMatches(pattern).Valid(t, r) - StderrMatches(pattern).Valid(t, r) - }, - name: fmt.Sprintf("combined-matches"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + matches(t, "stderr", pattern, r.Stderr) } } func matches(t *testing.T, name, pattern string, target []byte) { slog.Helper() + fields := []slog.Field{ + slog.F("pattern", pattern), + slog.F("target", string(target)), + slog.F("sink", name), + } ok, err := regexp.Match(pattern, target) if err != nil { - slogtest.Fatal(t, "failed to attempt regexp match", slog.Error(err), - slog.F("pattern", pattern), - slog.F("target", string(target)), - slog.F("sink", name), - ) + slogtest.Fatal(t, "failed to attempt regexp match", append(fields, slog.Error(err))...) } if !ok { - slogtest.Fatal(t, "expected to find pattern, no match found", - slog.F("pattern", pattern), - slog.F("target", string(target)), - slog.F("sink", name), - ) + slogtest.Fatal(t, "expected to find pattern, no match found", fields...) } } @@ -371,32 +312,26 @@ func empty(t *testing.T, name string, a []byte) { // DurationLessThan asserts that the command completed in less than the given duration func DurationLessThan(dur time.Duration) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - if r.Duration > dur { - slogtest.Fatal(t, "duration longer than expected", - slog.F("expected_less_than", dur.String), - slog.F("actual", r.Duration.String()), - ) - } - }, - name: fmt.Sprintf("duration-lessthan"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + if r.Duration > dur { + slogtest.Fatal(t, "duration longer than expected", + slog.F("expected_less_than", dur.String), + slog.F("actual", r.Duration.String()), + ) + } } } // DurationGreaterThan asserts that the command completed in greater than the given duration func DurationGreaterThan(dur time.Duration) Assertion { - return simpleFuncAssert{ - valid: func(t *testing.T, r *CommandResult) { - slog.Helper() - if r.Duration < dur { - slogtest.Fatal(t, "duration shorter than expected", - slog.F("expected_greater_than", dur.String), - slog.F("actual", r.Duration.String()), - ) - } - }, - name: fmt.Sprintf("duration-greaterthan"), + return func(t *testing.T, r *CommandResult) { + slog.Helper() + if r.Duration < dur { + slogtest.Fatal(t, "duration shorter than expected", + slog.F("expected_greater_than", dur.String), + slog.F("actual", r.Duration.String()), + ) + } } } From 18b5f50abcca7b5c7e79cd46ee93b084be0bf5fd Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Wed, 29 Jul 2020 10:09:20 -0500 Subject: [PATCH 18/19] Cleanup when commands are executed in sh or not --- ci/integration/integration_test.go | 2 +- ci/tcli/doc.go | 2 +- ci/tcli/tcli.go | 71 +++++++++++++----------------- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go index 3fb031be..e14edd6e 100644 --- a/ci/integration/integration_test.go +++ b/ci/integration/integration_test.go @@ -46,7 +46,7 @@ func init() { // write session tokens to the given container runner func headlessLogin(ctx context.Context, t *testing.T, runner *tcli.ContainerRunner) { creds := login(ctx, t) - cmd := exec.CommandContext(ctx, "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") + cmd := exec.CommandContext(ctx, "sh", "-c", "mkdir -p ~/.config/coder && cat > ~/.config/coder/session") // !IMPORTANT: be careful that this does not appear in logs cmd.Stdin = strings.NewReader(creds.token) diff --git a/ci/tcli/doc.go b/ci/tcli/doc.go index f82d3df3..561dc480 100644 --- a/ci/tcli/doc.go +++ b/ci/tcli/doc.go @@ -1,4 +1,4 @@ // Package tcli provides a framework for CLI integration testing. -// Execute commands on the raw host of inside docker container. +// Execute commands on the raw host or inside a docker container. // Define custom Assertion types to extend test functionality. package tcli diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index de9fab86..7aa2f6fd 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -102,23 +102,40 @@ func (r *ContainerRunner) Close() error { return nil } +// Run executes the given command in the runtime container with reasonable defaults. +// "command" is executed in a shell as an argument to "sh -c". +func (r *ContainerRunner) Run(ctx context.Context, command string) *Assertable { + cmd := exec.CommandContext(ctx, + "docker", "exec", "-i", r.name, + "sh", "-c", command, + ) + + return &Assertable{ + cmd: cmd, + tname: command, + } +} + +// RunCmd lifts the given *exec.Cmd into the runtime container +func (r *ContainerRunner) RunCmd(cmd *exec.Cmd) *Assertable { + path, _ := exec.LookPath("docker") + cmd.Path = path + command := strings.Join(cmd.Args, " ") + cmd.Args = append([]string{"docker", "exec", "-i", r.name}, cmd.Args...) + + return &Assertable{ + cmd: cmd, + tname: command, + } +} + // HostRunner executes command tests on the host, outside of a container type HostRunner struct{} -// Run executes the given command on the host +// Run executes the given command on the host. +// "command" is executed in a shell as an argument to "sh -c". func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { - var ( - args []string - path string - parts = strings.Split(command, " ") - ) - if len(parts) > 0 { - path = parts[0] - } - if len(parts) > 1 { - args = parts[1:] - } - cmd := exec.CommandContext(ctx, path, args...) + cmd := exec.CommandContext(ctx, "sh", "-c", command) return &Assertable{ cmd: cmd, @@ -126,7 +143,7 @@ func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { } } -// RunCmd executes the given command on the host +// RunCmd executes the given *exec.Cmd on the host func (r *HostRunner) RunCmd(cmd *exec.Cmd) *Assertable { return &Assertable{ cmd: cmd, @@ -145,32 +162,6 @@ type Assertable struct { tname string } -// Run executes the given command in the runtime container with reasonable defaults -func (r *ContainerRunner) Run(ctx context.Context, command string) *Assertable { - cmd := exec.CommandContext(ctx, - "docker", "exec", "-i", r.name, - "sh", "-c", command, - ) - - return &Assertable{ - cmd: cmd, - tname: command, - } -} - -// RunCmd lifts the given *exec.Cmd into the runtime container -func (r *ContainerRunner) RunCmd(cmd *exec.Cmd) *Assertable { - path, _ := exec.LookPath("docker") - cmd.Path = path - command := strings.Join(cmd.Args, " ") - cmd.Args = []string{"docker", "exec", "-i", r.name, "sh", "-c", command} - - return &Assertable{ - cmd: cmd, - tname: command, - } -} - // Assert runs the Assertable and func (a Assertable) Assert(t *testing.T, option ...Assertion) { slog.Helper() From 692b219e64bba190f55c9c121072353e05fb8da6 Mon Sep 17 00:00:00 2001 From: Charlie Moog Date: Wed, 29 Jul 2020 12:34:44 -0500 Subject: [PATCH 19/19] Handle command failure better --- ci/tcli/tcli.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ci/tcli/tcli.go b/ci/tcli/tcli.go index 7aa2f6fd..101cc926 100644 --- a/ci/tcli/tcli.go +++ b/ci/tcli/tcli.go @@ -181,8 +181,7 @@ func (a Assertable) Assert(t *testing.T, option ...Assertion) { if exitErr, ok := err.(*exec.ExitError); ok { result.ExitCode = exitErr.ExitCode() } else if err != nil { - // TODO: handle this case better - result.ExitCode = -1 + slogtest.Fatal(t, "command failed to run", slog.Error(err), slog.F("command", a.cmd)) } else { result.ExitCode = 0 }