diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index d246670f..50cdd7d9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,5 +1,4 @@ name: build - on: [push] jobs: @@ -9,7 +8,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/integration.yaml b/.github/workflows/integration.yaml new file mode 100644 index 00000000..a8045e43 --- /dev/null +++ b/.github/workflows/integration.yaml @@ -0,0 +1,26 @@ +name: integration +on: + push: + schedule: + - cron: '*/180 * * * *' + +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 -v ./ci/integration/... diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 00000000..36397b7d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,46 @@ +name: test +on: [push] + +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 ./internal/... ./cmd/... diff --git a/.gitignore b/.gitignore index 5fd924f8..c3d7f6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .idea ci/bin cmd/coder/coder +ci/integration/bin +ci/integration/env.sh \ No newline at end of file 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/integration/integration_test.go b/ci/integration/integration_test.go new file mode 100644 index 00000000..e14edd6e --- /dev/null +++ b/ci/integration/integration_test.go @@ -0,0 +1,120 @@ +package integration + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "cdr.dev/coder-cli/ci/tcli" + "cdr.dev/slog/sloggers/slogtest/assert" +) + +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") + + _, err := cmd.CombinedOutput() + if err != nil { + return err + } + return nil +} + +var binpath string + +func init() { + cwd, err := os.Getwd() + if err != nil { + panic(err) + } + + binpath = filepath.Join(cwd, "bin", "coder") + err = build(binpath) + if err != nil { + panic(err) + } +} + +// 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, "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) + 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) + defer cancel() + + c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ + Image: "codercom/enterprise-dev", + Name: "coder-cli-tests", + BindMounts: map[string]string{ + binpath: "/bin/coder", + }, + }) + 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(), + tcli.StdoutMatches("linux"), + ) + + c.Run(ctx, "coder help").Assert(t, + tcli.Success(), + tcli.StderrMatches("Commands:"), + tcli.StderrMatches("Usage: coder"), + tcli.StdoutEmpty(), + ) + + headlessLogin(ctx, t, c) + + 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/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..bb4b0d2c --- /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/steps/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 diff --git a/ci/tcli/doc.go b/ci/tcli/doc.go new file mode 100644 index 00000000..561dc480 --- /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 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 new file mode 100644 index 00000000..101cc926 --- /dev/null +++ b/ci/tcli/tcli.go @@ -0,0 +1,327 @@ +package tcli + +import ( + "bytes" + "context" + "fmt" + "io" + "os/exec" + "regexp" + "strings" + "testing" + "time" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "cdr.dev/slog/sloggers/slogtest/assert" + "golang.org/x/xerrors" +) + +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 ContainerRunner configuration schema for initializing a testing environment +type ContainerConfig struct { + 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("type=bind,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 +} + +// 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 + } + + args := []string{ + "run", + "--name", config.Name, + "-it", "-d", + } + args = append(args, mountArgs(config.BindMounts)...) + 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", + config.Name, string(out), err) + } + + return &ContainerRunner{ + name: config.Name, + ctx: ctx, + }, nil +} + +// Close kills and removes the command execution testing container +func (r *ContainerRunner) 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 +} + +// 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. +// "command" is executed in a shell as an argument to "sh -c". +func (r *HostRunner) Run(ctx context.Context, command string) *Assertable { + cmd := exec.CommandContext(ctx, "sh", "-c", command) + + return &Assertable{ + cmd: cmd, + tname: command, + } +} + +// RunCmd executes the given *exec.Cmd on the host +func (r *HostRunner) RunCmd(cmd *exec.Cmd) *Assertable { + return &Assertable{ + cmd: cmd, + tname: strings.Join(cmd.Args, " "), + } +} + +// Close is a noop for HostRunner +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 + tname string +} + +// Assert runs the Assertable and +func (a Assertable) Assert(t *testing.T, option ...Assertion) { + slog.Helper() + var ( + stdout bytes.Buffer + stderr bytes.Buffer + result CommandResult + ) + + a.cmd.Stdout = &stdout + a.cmd.Stderr = &stderr + + start := time.Now() + err := a.cmd.Run() + result.Duration = time.Since(start) + + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else if err != nil { + slogtest.Fatal(t, "command failed to run", slog.Error(err), slog.F("command", a.cmd)) + } else { + result.ExitCode = 0 + } + + result.Stdout = stdout.Bytes() + result.Stderr = stderr.Bytes() + + slogtest.Info(t, "command output", + slog.F("command", a.cmd), + slog.F("stdout", string(result.Stdout)), + slog.F("stderr", string(result.Stderr)), + slog.F("exit_code", result.ExitCode), + slog.F("duration", result.Duration), + ) + + for _, assertion := range option { + assertion(t, &result) + } +} + +// Assertion specifies an assertion on the given CommandResult. +// 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 { + Stdout, Stderr []byte + ExitCode int + Duration time.Duration +} + +// 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 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 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 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 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 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 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 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", append(fields, slog.Error(err))...) + } + if !ok { + slogtest.Fatal(t, "expected to find pattern, no match found", fields...) + } +} + +func empty(t *testing.T, name string, a []byte) { + slog.Helper() + if len(a) > 0 { + slogtest.Fatal(t, "expected "+name+" to be empty", slog.F("got", string(a))) + } +} + +// DurationLessThan asserts that the command completed in less than the given duration +func DurationLessThan(dur time.Duration) Assertion { + 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 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()), + ) + } + } +} 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), + ) +} 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/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 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 }