diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index f2976dc1..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - time: "06:00" - timezone: "America/Chicago" - commit-message: - prefix: "chore" - ignore: - # These actions deliver the latest versions by updating the major - # release tag, so ignore minor and patch versions - - dependency-name: "actions/*" - update-types: - - "version-update:semver-minor" - - "version-update:semver-patch" - - dependency-name: "Apple-Actions/import-codesign-certs" - update-types: - - "version-update:semver-minor" - - "version-update:semver-patch" - - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "daily" - time: "06:00" - timezone: "America/Chicago" - commit-message: - prefix: "chore" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index a0d76acf..00000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: build -on: [push] - -jobs: - build: - runs-on: ubuntu-20.04 - steps: - - name: Checkout - uses: actions/checkout@v1 - - uses: actions/setup-go@v2 - with: - go-version: '^1.16.3' - - name: Build - run: make -j build/linux build/windows - - name: Upload - uses: actions/upload-artifact@v2 - with: - name: coder-cli - path: ./ci/bin/coder-cli-* - build_darwin: - runs-on: macos-latest - steps: - - name: Checkout - uses: actions/checkout@v1 - - uses: actions/setup-go@v2 - with: - go-version: '^1.16.3' - - name: Install Gon - run: | - brew tap mitchellh/gon - brew install mitchellh/gon/gon - - name: Import Signing Certificates - uses: Apple-Actions/import-codesign-certs@v1 - with: - p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} - p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} - - name: Build - run: make build/macos - env: - AC_USERNAME: ${{ secrets.AC_USERNAME }} - AC_PASSWORD: ${{ secrets.AC_PASSWORD }} - - name: Upload - uses: actions/upload-artifact@v2 - with: - name: coder-cli - path: ./ci/bin/coder-cli-* diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml deleted file mode 100644 index e7abedaa..00000000 --- a/.github/workflows/integration.yaml +++ /dev/null @@ -1,26 +0,0 @@ -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@v2 - 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: integration tests - run: ./ci/scripts/integration.sh diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 4a0606fb..00000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,105 +0,0 @@ -on: - create: - tags: "v*" -name: create_github_release -jobs: - build: - name: Build binaries - runs-on: ubuntu-20.04 - steps: - - name: Checkout code - uses: actions/checkout@v2 - - uses: actions/setup-go@v2 - with: - go-version: '^1.16.3' - - name: Build - run: make -j build/linux build/windows - - name: Upload linux - uses: actions/upload-artifact@v2 - with: - name: coder-cli-linux-amd64 - path: ./ci/bin/coder-cli-linux-amd64.tar.gz - - name: Upload windows - uses: actions/upload-artifact@v2 - with: - name: coder-cli-windows - path: ./ci/bin/coder-cli-windows.zip - build_darwin: - name: Build darwin binary - runs-on: macos-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Install Gon - run: | - brew tap mitchellh/gon - brew install mitchellh/gon/gon - - name: Import Signing Certificates - uses: Apple-Actions/import-codesign-certs@v1 - with: - p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} - p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} - - uses: actions/setup-go@v2 - with: - go-version: '^1.16.3' - - name: Build Release Assets - run: make build/macos - env: - AC_USERNAME: ${{ secrets.AC_USERNAME }} - AC_PASSWORD: ${{ secrets.AC_PASSWORD }} - - name: Upload darwin - uses: actions/upload-artifact@v2 - with: - name: coder-cli-darwin-amd64 - path: ./ci/bin/coder-cli-darwin-amd64.zip - draft_release: - name: Create Release - runs-on: ubuntu-20.04 - needs: - - build_darwin - - build - steps: - - uses: actions/download-artifact@v2 - - name: content - run: sh -c "ls -al" - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.ref }} - release_name: ${{ github.ref }} - body: "" - draft: true - prerelease: false - - name: Upload Linux Release - id: upload-linux-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: coder-cli-linux-amd64/coder-cli-linux-amd64.tar.gz - asset_name: coder-cli-linux-amd64.tar.gz - asset_content_type: application/tar+gzip - - name: Upload MacOS Release - id: upload-macos-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: coder-cli-darwin-amd64/coder-cli-darwin-amd64.zip - asset_name: coder-cli-darwin-amd64.zip - asset_content_type: application/zip - - name: Upload Windows Release - id: upload-windows-release-asset - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: coder-cli-windows/coder-cli-windows.zip - asset_name: coder-cli-windows.zip - asset_content_type: application/zip diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml deleted file mode 100644 index 79a324a9..00000000 --- a/.github/workflows/test.yaml +++ /dev/null @@ -1,74 +0,0 @@ -name: test -on: [push] - -jobs: - fmt: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - - uses: actions/cache@v2 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: fmt - uses: ./ci/image - with: - args: make -j fmt - - - run: ./ci/scripts/files_changed.sh - - lint: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - - name: golangci-lint - uses: golangci/golangci-lint-action@v2.5.2 - with: - # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.39 - - test: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - - uses: actions/cache@v2 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: test - uses: ./ci/image - env: - COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CODER_URL: ${{ secrets.CODER_URL }} - CODER_EMAIL: ${{ secrets.CODER_EMAIL }} - CODER_PASSWORD: ${{ secrets.CODER_PASSWORD }} - with: - args: make -j test/coverage - - gendocs: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v2 - - - uses: actions/cache@v2 - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: generate-docs - uses: ./ci/image - with: - args: make -j gendocs - - - run: ./ci/scripts/files_changed.sh diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 11433c18..00000000 --- a/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -./coder -.idea -ci/bin -cmd/coder/coder -ci/integration/bin -ci/integration/env.sh -coder-sdk/env.sh -.vscode -vendor \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml deleted file mode 100644 index df85d74b..00000000 --- a/.golangci.yml +++ /dev/null @@ -1,120 +0,0 @@ -# See https://golangci-lint.run/usage/configuration/ -linters-settings: - gocognit: - # tunnel.go has a 150 line function. Someone should fix it and - # decrement this back down to a rational number. - min-complexity: 52 - goconst: - min-len: 4 - min-occurrences: 3 - nestif: - min-complexity: 10 - revive: - # see https://github.com/mgechev/revive#available-rules for details. - ignore-generated-header: true - severity: warning - rules: - - name: atomic - # - name: bare-return - - name: blank-imports - - name: bool-literal-in-expr - - name: call-to-gc - - name: confusing-naming - - name: confusing-results - - name: constant-logical-expr - - name: context-as-argument - - name: context-keys-type - # - name: deep-exit - - name: defer - - name: dot-imports - - name: duplicated-imports - # - name: early-return - # - name: empty-block - - name: empty-lines - - name: error-naming - - name: error-return - - name: error-strings - - name: errorf - - name: exported - # - name: flag-parameter - - name: get-return - - name: identical-branches - # - name: if-return - # - name: import-shadowing - - name: increment-decrement - - name: indent-error-flow - - name: modifies-parameter - - name: modifies-value-receiver - - name: package-comments - - name: range - - name: range-val-address - - name: range-val-in-closure - - name: receiver-naming - - name: redefines-builtin-id - - name: string-of-int - - name: struct-tag - - name: superfluous-else - - name: time-naming - - name: unconditional-recursion - - name: unexported-naming - - name: unexported-return - # - name: unhandled-error - - name: unnecessary-stmt - - name: unreachable-code - # - name: unused-parameter - # - name: unused-receiver - # - name: var-declaration - - name: var-naming - - name: waitgroup-by-value - -issues: - fix: true - max-issues-per-linter: 0 - max-same-issues: 0 - -run: - timeout: 5m - -linters: - disable-all: true - enable: - - bodyclose - - deadcode - - dogsled - - errcheck - # - errorlint - - exportloopref - # - forcetypeassert - - gocognit - - goconst - - gocritic - - gocyclo - - godot - - gofmt - - goimports - - golint - - gomodguard - - goprintffuncname - # - gosec - - gosimple - - govet - - ineffassign - - makezero - - megacheck - - misspell - - nestif - - noctx - - nolintlint - - revive - - rowserrcheck - - sqlclosecheck - - staticcheck - - structcheck - - stylecheck - - typecheck - - unconvert - - unparam - - unused - - varcheck - - wastedassign - - whitespace diff --git a/Makefile b/Makefile deleted file mode 100644 index aa80df36..00000000 --- a/Makefile +++ /dev/null @@ -1,51 +0,0 @@ -# Makefile for Coder CLI - -.PHONY: clean build build/macos build/windows build/linux fmt lint gendocs test/go dev - -PROJECT_ROOT := $(shell git rev-parse --show-toplevel) -MAKE_ROOT := $(shell pwd) - -clean: - rm -rf ./ci/bin - -build: build/macos build/windows build/linux - -build/macos: - # requires darwin - CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 ./ci/scripts/build.sh -build/windows: - CGO_ENABLED=0 GOOS=windows ./ci/scripts/build.sh -build/linux: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 ./ci/scripts/build.sh - -fmt: - go mod tidy - gofmt -w -s . - goimports -w "-local=$$(go list -m)" . - -lint: - golangci-lint run -c .golangci.yml - -gendocs: - rm -rf ./docs - mkdir ./docs - go run ./cmd/coder gen-docs ./docs - -test/go: - go test $$(go list ./... | grep -v pkg/tcli | grep -v ci/integration) - -test/coverage: - go test \ - -race \ - -covermode atomic \ - -coverprofile coverage \ - $$(go list ./... | grep -v pkg/tcli | grep -v ci/integration) - - goveralls -coverprofile=coverage -service=github - -dev: build/linux - @echo "removing project root binary if exists" - -rm ./coder - @echo "untarring..." - @tar -xzf ./ci/bin/coder-cli-linux-amd64.tar.gz - @echo "new dev binary ready" diff --git a/README.md b/README.md index 9757c3ec..4fbeb053 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,13 @@ -# Coder CLI +# Coder v1 CLI [![GitHub Release](https://img.shields.io/github/v/release/cdr/coder-cli?color=6b9ded&include_prerelease=false)](https://github.com/cdr/coder-cli/releases) [![Documentation](https://godoc.org/cdr.dev/coder-cli?status.svg)](https://pkg.go.dev/cdr.dev/coder-cli/coder-sdk) -![build](https://github.com/cdr/coder-cli/workflows/build/badge.svg) -[![Go Report Card](https://goreportcard.com/badge/cdr.dev/coder-cli)](https://goreportcard.com/report/cdr.dev/coder-cli) -`coder` is a command line utility for Coder. +This is the command line utility for [Coder v1](https://coder.com/docs/coder). If you are using +[Coder v2 / Coder OSS](https://coder.com/docs/coder-oss/latest), use +[these instructions](https://coder.com/docs/coder-oss/latest/install) to install the CLI. -To report bugs and request features, please [open an issue](https://github.com/cdr/coder-cli/issues/new). +The Coder v1 CLI is now closed-source. You may download binary releases from this repo. -## Installation - -### Homebrew (Mac, Linux) - -```sh -brew install cdr/coder/coder-cli -``` - -### Download (Windows, Linux, Mac) - -Download the latest [release](https://github.com/cdr/coder-cli/releases): - -1. Click a release and download the tar file for your operating system (ex: coder-cli-linux-amd64.tar.gz) -2. Extract the `coder` binary. - -## Usage - -View the usage documentation [here](./docs/coder.md). - -You can find additional Coder usage documentation on [coder.com/docs/cli](https://coder.com/docs/cli). +[Coder v2](https://coder.com/docs/coder-oss/latest) is open-source and the recommended +version for new Coder users. \ No newline at end of file diff --git a/ci/README.md b/ci/README.md deleted file mode 100644 index 1daee639..00000000 --- a/ci/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# ci - -## checks - -- `steps/build.sh` builds release assets with the appropriate tag injected. Required to pass for merging. -- `steps/fmt.sh` formats all Go source files. -- `steps/gendocs.sh` generates CLI documentation into `/docs` from the command specifications. -- `steps/lint.sh` lints all Go source files based on the rules set fourth in `/.golangci.yml`. - - -## integration tests - -### `tcli` - -Package `tcli` provides a framework for writing end-to-end CLI tests. -Each test group can have its own container for executing commands in a consistent -and isolated filesystem. - -### running - -Assign the following environment variables to run the integration tests -against an existing Enterprise deployment instance. - -```bash -export CODER_URL=... -export CODER_EMAIL=... -export CODER_PASSWORD=... -``` - -Then, simply run the test command from the project root - -```sh -./ci/steps/integration.sh -``` diff --git a/ci/gon.json b/ci/gon.json deleted file mode 100644 index 0762638f..00000000 --- a/ci/gon.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "source": ["./coder"], - "bundle_id": "com.coder.cli", - "sign": { - "application_identity": "3C4F31D15F9D57461A8D7D0BD970D23CE1F7C2BE" - }, - "zip": { - "output_path": "coder.zip" - } -} \ No newline at end of file diff --git a/ci/image/Dockerfile b/ci/image/Dockerfile deleted file mode 100644 index b77bf8aa..00000000 --- a/ci/image/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -FROM golang:1.16.5 - -ENV GOFLAGS="-mod=readonly" -ENV CI=true - -RUN go get golang.org/x/tools/cmd/goimports -RUN go get github.com/mattn/goveralls -RUN apt update && apt install grep diff --git a/ci/integration/Dockerfile b/ci/integration/Dockerfile deleted file mode 100644 index 70dcc2c0..00000000 --- a/ci/integration/Dockerfile +++ /dev/null @@ -1,3 +0,0 @@ -FROM ubuntu:20.04 - -RUN apt-get update && apt-get install -y jq curl build-essential diff --git a/ci/integration/integration_test.go b/ci/integration/integration_test.go deleted file mode 100644 index 8654965a..00000000 --- a/ci/integration/integration_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package integration - -import ( - "context" - "math/rand" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/pkg/tcli" -) - -func run(t *testing.T, container string, execute func(t *testing.T, ctx context.Context, runner *tcli.ContainerRunner)) { - t.Run(container, func(t *testing.T) { - t.Parallel() - - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) - defer cancel() - - c, err := tcli.NewContainerRunner(ctx, &tcli.ContainerConfig{ - Image: "coder-cli-integration:latest", - Name: container, - BindMounts: map[string]string{ - binpath: "/bin/coder", - }, - }) - assert.Success(t, "new run container", err) - defer c.Close() - - execute(t, ctx, c) - }) -} - -func TestCoderCLI(t *testing.T) { - t.Parallel() - run(t, "test-coder-cli", func(t *testing.T, ctx context.Context, c *tcli.ContainerRunner) { - c.Run(ctx, "which coder").Assert(t, - tcli.Success(), - 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.StdoutMatches("Available Commands"), - ) - - headlessLogin(ctx, t, c) - - c.Run(ctx, "coder workspaces").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder workspaces ls").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder workspaces ls -o json").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder tokens").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder tokens ls").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder tokens ls -o json").Assert(t, - tcli.Success(), - ) - - c.Run(ctx, "coder urls").Assert(t, - tcli.Success(), - ) - - 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 workspaces ls").Assert(t, - tcli.Error(), - ) - - c.Run(ctx, "coder tokens ls").Assert(t, - tcli.Error(), - ) - }) -} - -var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) - -func randString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz" - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} diff --git a/ci/integration/login_test.go b/ci/integration/login_test.go deleted file mode 100644 index e0334f00..00000000 --- a/ci/integration/login_test.go +++ /dev/null @@ -1,75 +0,0 @@ -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/integration/setup_test.go b/ci/integration/setup_test.go deleted file mode 100644 index 45cb7f04..00000000 --- a/ci/integration/setup_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package integration - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/pkg/tcli" -) - -// binpath is populated during package initialization with a path to the coder binary. -var binpath string - -// initialize integration tests by building the coder-cli binary. -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) - } -} - -// build the coder-cli binary and move to the integration testing bin directory. -func build(path string) error { - tar := "coder-cli-linux-amd64.tar.gz" - dir := filepath.Dir(path) - cmd := exec.Command( - "sh", "-c", - fmt.Sprintf( - "cd ../../ && mkdir -p %s && make build/linux && cp ./ci/bin/%s %s/ && tar -xzf %s -C %s", - dir, tar, dir, filepath.Join(dir, tar), dir), - ) - - out, err := cmd.CombinedOutput() - if err != nil { - return xerrors.Errorf("build coder-cli (%v): %w", string(out), err) - } - return nil -} - -// 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 $HOME/.config/coder && cat > $HOME/.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(), - ) - - cmd = exec.CommandContext(ctx, "sh", "-c", "cat > $HOME/.config/coder/url") - cmd.Stdin = strings.NewReader(creds.url) - runner.RunCmd(cmd).Assert(t, - tcli.Success(), - ) -} diff --git a/ci/integration/ssh_test.go b/ci/integration/ssh_test.go deleted file mode 100644 index 5844ca93..00000000 --- a/ci/integration/ssh_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package integration - -import ( - "context" - "testing" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/tcli" -) - -func TestSSH(t *testing.T) { - t.Parallel() - run(t, "ssh-coder-cli-tests", func(t *testing.T, ctx context.Context, c *tcli.ContainerRunner) { - headlessLogin(ctx, t, c) - - // TODO remove this once we can create a workspace if there aren't any - var workspaces []coder.Workspace - c.Run(ctx, "coder workspaces ls --output json").Assert(t, - tcli.Success(), - tcli.StdoutJSONUnmarshal(&workspaces), - ) - - assert := tcli.Success() - - // if we don't have any workspaces, "coder config-ssh" will fail - if len(workspaces) == 0 { - assert = tcli.Error() - } - c.Run(ctx, "coder config-ssh").Assert(t, - assert, - ) - }) -} diff --git a/ci/integration/statictokens_test.go b/ci/integration/statictokens_test.go deleted file mode 100644 index b1de474c..00000000 --- a/ci/integration/statictokens_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package integration - -import ( - "context" - "fmt" - "os" - "os/exec" - "strings" - "testing" - - "cdr.dev/coder-cli/pkg/tcli" -) - -func TestStaticAuth(t *testing.T) { - t.Parallel() - t.Skip() - run(t, "static-auth-test", func(t *testing.T, ctx context.Context, c *tcli.ContainerRunner) { - headlessLogin(ctx, t, c) - - c.Run(ctx, "coder tokens ls").Assert(t, - tcli.Success(), - ) - - var result *tcli.CommandResult - tokenName := randString(5) - c.Run(ctx, "coder tokens create "+tokenName).Assert(t, - tcli.Success(), - tcli.GetResult(&result), - ) - - // remove loging credentials - c.Run(ctx, "rm -rf ~/.config/coder").Assert(t, - tcli.Success(), - ) - - // make requests with token environment variable authentication - cmd := exec.CommandContext(ctx, "sh", "-c", - fmt.Sprintf("export CODER_URL=%s && export CODER_TOKEN=$(cat) && coder workspaces ls", os.Getenv("CODER_URL")), - ) - cmd.Stdin = strings.NewReader(string(result.Stdout)) - c.RunCmd(cmd).Assert(t, - tcli.Success(), - ) - - // should error when the environment variables aren't set - c.Run(ctx, "coder workspaces ls").Assert(t, - tcli.Error(), - ) - }) -} diff --git a/ci/scripts/build.sh b/ci/scripts/build.sh deleted file mode 100755 index 821c062e..00000000 --- a/ci/scripts/build.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash - -# Make pushd and popd silent -pushd() { builtin pushd "$@" >/dev/null; } -popd() { builtin popd >/dev/null; } - -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)/ci/scripts" - -tag="$(git describe --tags)" - -flavor="$GOOS" -if [[ "$GOOS" == "windows" ]]; then - # GOARCH causes bugs with the safeexec package on Windows. - unset GOARCH -else - flavor+="-$GOARCH" -fi -echo "--- building coder-cli for $flavor" - -tmpdir="$(mktemp -d)" -go build -ldflags "-X cdr.dev/coder-cli/internal/version.Version=${tag}" -o "$tmpdir/coder" ../../cmd/coder - -cp ../gon.json $tmpdir/gon.json - -pushd "$tmpdir" -case "$GOOS" in -"windows") - artifact="coder-cli-$GOOS.zip" - mv coder coder.exe - zip "$artifact" coder.exe - ;; -"linux") - artifact="coder-cli-$GOOS-$GOARCH.tar.gz" - tar -czf "$artifact" coder - ;; -"darwin") - if [[ ${CI-} ]]; then - artifact="coder-cli-$GOOS-$GOARCH.zip" - gon -log-level debug ./gon.json - mv coder.zip $artifact - else - artifact="coder-cli-$GOOS-$GOARCH.tar.gz" - tar -czf "$artifact" coder - echo "--- warning: not in ci, skipping signed release of darwin" - fi - ;; -esac -popd - -mkdir -p ../bin -cp "$tmpdir/$artifact" ../bin/$artifact -rm -rf "$tmpdir" diff --git a/ci/scripts/files_changed.sh b/ci/scripts/files_changed.sh deleted file mode 100755 index 490cb5ad..00000000 --- a/ci/scripts/files_changed.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -cd "$(git rev-parse --show-toplevel)" - -if [[ $(git ls-files --other --modified --exclude-standard) ]]; then - echo "Files have changed:" - git ls-files --other --modified --exclude-standard - git -c color.ui=never status - exit 1 -fi diff --git a/ci/scripts/integration.sh b/ci/scripts/integration.sh deleted file mode 100755 index 6f82475c..00000000 --- a/ci/scripts/integration.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash - -set -eo pipefail - -cd "$(git rev-parse --show-toplevel)" - -echo "--- building integration test image" -docker build -f ./ci/integration/Dockerfile -t coder-cli-integration:latest . - -echo "--- starting integration tests" -go test ./ci/integration -count=1 diff --git a/cmd/coder/main.go b/cmd/coder/main.go deleted file mode 100644 index 93abeb3c..00000000 --- a/cmd/coder/main.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/http" - _ "net/http/pprof" - "os" - "runtime" - - "cdr.dev/coder-cli/internal/cmd" - "cdr.dev/coder-cli/internal/version" - "cdr.dev/coder-cli/internal/x/xterminal" - "cdr.dev/coder-cli/pkg/clog" -) - -func main() { - ctx, cancel := context.WithCancel(context.Background()) - - // If requested, spin up the pprof webserver. - if os.Getenv("PPROF") != "" { - go func() { - log.Println(http.ListenAndServe("localhost:6060", nil)) - }() - } - - restoreTerminal := func() {} - - // Janky, but SSH on windows sets the output to raw. - // If we set it ourselves, SSH fails because the FD isn't found. - if len(os.Args) >= 2 && os.Args[1] != "tunnel" { - state, err := xterminal.MakeOutputRaw(os.Stdout.Fd()) - if err != nil { - clog.Log(clog.Fatal(fmt.Sprintf("set output to raw: %s", err))) - cancel() - os.Exit(1) - } - restoreTerminal = func() { - // Best effort. Would result in broken terminal on window but nothing we can do about it. - _ = xterminal.Restore(os.Stdout.Fd(), state) - } - } - - app := cmd.Make() - app.Version = fmt.Sprintf("%s %s %s/%s", version.Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) - - if err := app.ExecuteContext(ctx); err != nil { - clog.Log(err) - cancel() - restoreTerminal() - os.Exit(1) - } - cancel() - restoreTerminal() -} diff --git a/coder-sdk/README.md b/coder-sdk/README.md deleted file mode 100644 index 75ffd8dd..00000000 --- a/coder-sdk/README.md +++ /dev/null @@ -1,10 +0,0 @@ -# coder-sdk - -`coder-sdk` is a Go client library for [Coder](https://coder.com). -It is not yet stable and therefore we do not recommend depending on the current state of its public APIs. - -## Usage - -```bash -go get cdr.dev/coder-cli/coder-sdk -``` diff --git a/coder-sdk/activity.go b/coder-sdk/activity.go deleted file mode 100644 index 23c75abd..00000000 --- a/coder-sdk/activity.go +++ /dev/null @@ -1,27 +0,0 @@ -package coder - -import ( - "context" - "net/http" -) - -type activityRequest struct { - Source string `json:"source"` - WorkspaceID string `json:"workspace_id"` -} - -// PushActivity pushes CLI activity to Coder. -func (c *DefaultClient) PushActivity(ctx context.Context, source, workspaceID string) error { - resp, err := c.request(ctx, http.MethodPost, "/api/private/metrics/usage/push", activityRequest{ - Source: source, - WorkspaceID: workspaceID, - }) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return NewHTTPError(resp) - } - return nil -} diff --git a/coder-sdk/activity_test.go b/coder-sdk/activity_test.go deleted file mode 100644 index ff3083a3..00000000 --- a/coder-sdk/activity_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package coder_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -func TestPushActivity(t *testing.T) { - t.Parallel() - - const source = "test" - const workspaceID = "602d377a-e6b8d763cae7561885c5f1b2" - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "PushActivity is a POST", http.MethodPost, r.Method) - assert.Equal(t, "URL matches", "/api/private/metrics/usage/push", r.URL.Path) - - expected := map[string]interface{}{ - "source": source, - "workspace_id": workspaceID, - } - var request map[string]interface{} - err := json.NewDecoder(r.Body).Decode(&request) - assert.Success(t, "error decoding JSON", err) - assert.Equal(t, "unexpected request data", expected, request) - - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(func() { - server.Close() - }) - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Token: "SwdcSoq5Jc-0C1r8wfwm7h6h9i0RDk7JT", - }) - assert.Success(t, "failed to create coder.Client", err) - - err = client.PushActivity(context.Background(), source, workspaceID) - assert.Success(t, "expected successful response from PushActivity", err) -} diff --git a/coder-sdk/agent.go b/coder-sdk/agent.go deleted file mode 100644 index 29052f11..00000000 --- a/coder-sdk/agent.go +++ /dev/null @@ -1,22 +0,0 @@ -package coder - -import ( - "context" - "fmt" - "net/http" -) - -// UpdateLastConnectionAt updates the last connection at attribute of a workspace. -func (c *DefaultClient) UpdateLastConnectionAt(ctx context.Context, workspaceID string) error { - reqURL := fmt.Sprintf("/api/private/envagent/%s/update-last-connection-at", workspaceID) - resp, err := c.request(ctx, http.MethodPost, reqURL, nil) - if err != nil { - return err - } - - if resp.StatusCode != http.StatusOK { - return NewHTTPError(resp) - } - - return nil -} diff --git a/coder-sdk/client.go b/coder-sdk/client.go deleted file mode 100644 index 6d172126..00000000 --- a/coder-sdk/client.go +++ /dev/null @@ -1,128 +0,0 @@ -package coder - -import ( - "context" - "errors" - "net/http" - "net/url" - "time" - - "golang.org/x/xerrors" -) - -// ensure that DefaultClient implements Client. -var _ = Client(&DefaultClient{}) - -// Me is the user ID of the authenticated user. -const Me = "me" - -// ClientOptions contains options for the Coder SDK Client. -type ClientOptions struct { - // BaseURL is the root URL of the Coder installation (required). - BaseURL *url.URL - - // Client is the http.Client to use for requests (optional). - // - // If omitted, the http.DefaultClient will be used. - HTTPClient *http.Client - - // Token is the API Token used to authenticate (optional). - // - // If Token is provided, the DefaultClient will use it to - // authenticate. If it is not provided, the client requires - // another type of credential, such as an Email/Password pair. - Token string - - // Email used to authenticate with Coder. - // - // If you supply an Email and Password pair, NewClient will - // exchange these credentials for a Token during initialization. - // This is only applicable for the built-in authentication - // provider. The client will not retain these credentials in - // memory after NewClient returns. - Email string - - // Password used to authenticate with Coder. - // - // If you supply an Email and Password pair, NewClient will - // exchange these credentials for a Token during initialization. - // This is only applicable for the built-in authentication - // provider. The client will not retain these credentials in - // memory after NewClient returns. - Password string -} - -// NewClient creates a new default Coder SDK client. -func NewClient(opts ClientOptions) (*DefaultClient, error) { - httpClient := opts.HTTPClient - if httpClient == nil { - httpClient = http.DefaultClient - } - - if opts.BaseURL == nil { - return nil, errors.New("the BaseURL parameter is required") - } - - token := opts.Token - if token == "" { - if opts.Email == "" || opts.Password == "" { - return nil, errors.New("either an API Token or email/password pair are required") - } - - // Exchange the username/password for a token. - // We apply a default timeout of 5 seconds here. - ctx := context.Background() - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - resp, err := LoginWithPassword(ctx, httpClient, opts.BaseURL, &LoginRequest{ - Email: opts.Email, - Password: opts.Password, - }) - if err != nil { - return nil, xerrors.Errorf("failed to login with email/password: %w", err) - } - - token = resp.SessionToken - if token == "" { - return nil, errors.New("server returned an empty session token") - } - } - - // TODO: add basic validation to make sure the token looks OK. - - client := &DefaultClient{ - baseURL: opts.BaseURL, - httpClient: httpClient, - token: token, - } - - return client, nil -} - -// DefaultClient is the default implementation of the coder.Client -// interface. -// -// The empty value is meaningless and the fields are unexported; -// use NewClient to create an instance. -type DefaultClient struct { - // baseURL is the URL (scheme, hostname/IP address, port, - // path prefix of the Coder installation) - baseURL *url.URL - - // httpClient is the http.Client used to issue requests. - httpClient *http.Client - - // token is the API Token credential. - token string -} - -// Token returns the API Token used to authenticate. -func (c *DefaultClient) Token() string { - return c.token -} - -// BaseURL returns the BaseURL configured for this Client. -func (c *DefaultClient) BaseURL() url.URL { - return *c.baseURL -} diff --git a/coder-sdk/client_test.go b/coder-sdk/client_test.go deleted file mode 100644 index 732678d0..00000000 --- a/coder-sdk/client_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package coder_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -func TestAuthentication(t *testing.T) { - t.Parallel() - - const token = "g4mtIPUaKt-pPl9Q0xmgKs7acSypHt4Jf" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotToken := r.Header.Get("Session-Token") - assert.Equal(t, "token does not match", token, gotToken) - - w.WriteHeader(http.StatusServiceUnavailable) - })) - t.Cleanup(func() { - server.Close() - }) - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Token: token, - }) - assert.Success(t, "failed to create coder.Client", err) - - assert.Equal(t, "expected Token to match", token, client.Token()) - assert.Equal(t, "expected BaseURL to match", *u, client.BaseURL()) - - _, err = client.APIVersion(context.Background()) - assert.Success(t, "failed to get API version information", err) -} - -func TestPasswordAuthentication(t *testing.T) { - t.Parallel() - - const email = "user@coder.com" - const password = "coder4all" - const token = "g4mtIPUaKt-pPl9Q0xmgKs7acSypHt4Jf" - - mux := http.NewServeMux() - mux.HandleFunc("/auth/basic/login", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "login is a POST", http.MethodPost, r.Method) - - expected := map[string]interface{}{ - "email": email, - "password": password, - } - var request map[string]interface{} - err := json.NewDecoder(r.Body).Decode(&request) - assert.Success(t, "error decoding JSON", err) - assert.Equal(t, "unexpected request data", expected, request) - - response := map[string]interface{}{ - "session_token": token, - } - - w.WriteHeader(http.StatusOK) - err = json.NewEncoder(w).Encode(response) - assert.Success(t, "error encoding JSON", err) - }) - mux.HandleFunc("/api/v0/users/me", func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Users is a GET", http.MethodGet, r.Method) - - gotToken := r.Header.Get("Session-Token") - assert.Equal(t, "expected session token to match return of login", token, gotToken) - - user := map[string]interface{}{ - "id": "default", - "email": email, - "username": "charlie", - "name": "Charlie Root", - "roles": []coder.Role{coder.SiteAdmin}, - "temporary_password": false, - "login_type": coder.LoginTypeBuiltIn, - "key_regenerated_at": time.Now(), - "created_at": time.Now(), - "updated_at": time.Now(), - } - - w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(user) - assert.Success(t, "error encoding JSON", err) - }) - server := httptest.NewTLSServer(mux) - t.Cleanup(func() { - server.Close() - }) - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - assert.Equal(t, "expected HTTPS base URL", "https", u.Scheme) - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - HTTPClient: server.Client(), - Email: email, - Password: password, - }) - assert.Success(t, "failed to create Client", err) - assert.Equal(t, "expected token to match", token, client.Token()) - - user, err := client.Me(context.Background()) - assert.Success(t, "failed to get information about current user", err) - assert.Equal(t, "expected test user", email, user.Email) -} - -func TestContextRoot(t *testing.T) { - t.Parallel() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Users is a GET", http.MethodGet, r.Method) - assert.Equal(t, "expected context root", "/context-root/api/v0/users", r.URL.Path) - - w.WriteHeader(http.StatusServiceUnavailable) - })) - t.Cleanup(func() { - server.Close() - }) - - contextRoots := []string{ - "/context-root", - "/context-root/", - } - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - - for _, prefix := range contextRoots { - u.Path = prefix - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Token: "FrOgA6xhpM-p5nTfsupmvzYJA6DJSOUoE", - }) - assert.Success(t, "failed to create coder.Client", err) - - _, err = client.Users(context.Background()) - assert.Error(t, "expected 503 error", err) - } -} diff --git a/coder-sdk/config.go b/coder-sdk/config.go deleted file mode 100644 index c43ddf2c..00000000 --- a/coder-sdk/config.go +++ /dev/null @@ -1,157 +0,0 @@ -package coder - -import ( - "context" - "net/http" -) - -// AuthProviderType is an enum of each valid auth provider. -type AuthProviderType string - -// AuthProviderType enum. -const ( - AuthProviderBuiltIn AuthProviderType = "built-in" - AuthProviderSAML AuthProviderType = "saml" - AuthProviderOIDC AuthProviderType = "oidc" -) - -// ConfigAuth describes the authentication configuration for a Coder deployment. -type ConfigAuth struct { - ProviderType *AuthProviderType `json:"provider_type"` - OIDC *ConfigOIDC `json:"oidc"` - SAML *ConfigSAML `json:"saml"` -} - -// ConfigOIDC describes the OIDC configuration for single-signon support in Coder. -type ConfigOIDC struct { - ClientID *string `json:"client_id"` - ClientSecret *string `json:"client_secret"` - Issuer *string `json:"issuer"` -} - -// ConfigSAML describes the SAML configuration values. -type ConfigSAML struct { - IdentityProviderMetadataURL *string `json:"idp_metadata_url"` - SignatureAlgorithm *string `json:"signature_algorithm"` - NameIDFormat *string `json:"name_id_format"` - PrivateKey *string `json:"private_key"` - PublicKeyCertificate *string `json:"public_key_certificate"` -} - -// ConfigOAuthBitbucketServer describes the Bitbucket integration configuration for a Coder deployment. -type ConfigOAuthBitbucketServer struct { - BaseURL string `json:"base_url" diff:"oauth.bitbucket_server.base_url"` -} - -// ConfigOAuthGitHub describes the Github integration configuration for a Coder deployment. -type ConfigOAuthGitHub struct { - BaseURL string `json:"base_url"` - ClientID string `json:"client_id"` - ClientSecret string `json:"client_secret"` -} - -// ConfigOAuthGitLab describes the GitLab integration configuration for a Coder deployment. -type ConfigOAuthGitLab struct { - BaseURL string `json:"base_url"` - ClientID string `json:"client_id" ` - ClientSecret string `json:"client_secret"` -} - -// ConfigOAuth describes the aggregate git integration configuration for a Coder deployment. -type ConfigOAuth struct { - BitbucketServer ConfigOAuthBitbucketServer `json:"bitbucket_server"` - GitHub ConfigOAuthGitHub `json:"github"` - GitLab ConfigOAuthGitLab `json:"gitlab"` -} - -// SiteConfigAuth fetches the sitewide authentication configuration. -func (c *DefaultClient) SiteConfigAuth(ctx context.Context) (*ConfigAuth, error) { - var conf ConfigAuth - if err := c.requestBody(ctx, http.MethodGet, "/api/private/auth/config", nil, &conf); err != nil { - return nil, err - } - return &conf, nil -} - -// PutSiteConfigAuth sets the sitewide authentication configuration. -func (c *DefaultClient) PutSiteConfigAuth(ctx context.Context, req ConfigAuth) error { - return c.requestBody(ctx, http.MethodPut, "/api/private/auth/config", req, nil) -} - -// SiteConfigOAuth fetches the sitewide git provider OAuth configuration. -func (c *DefaultClient) SiteConfigOAuth(ctx context.Context) (*ConfigOAuth, error) { - var conf ConfigOAuth - if err := c.requestBody(ctx, http.MethodGet, "/api/private/oauth/config", nil, &conf); err != nil { - return nil, err - } - return &conf, nil -} - -// PutSiteConfigOAuth sets the sitewide git provider OAuth configuration. -func (c *DefaultClient) PutSiteConfigOAuth(ctx context.Context, req ConfigOAuth) error { - return c.requestBody(ctx, http.MethodPut, "/api/private/oauth/config", req, nil) -} - -type configSetupMode struct { - SetupMode bool `json:"setup_mode"` -} - -// SiteSetupModeEnabled fetches the current setup_mode state of a Coder deployment. -func (c *DefaultClient) SiteSetupModeEnabled(ctx context.Context) (bool, error) { - var conf configSetupMode - if err := c.requestBody(ctx, http.MethodGet, "/api/private/config/setup-mode", nil, &conf); err != nil { - return false, err - } - return conf.SetupMode, nil -} - -// ExtensionMarketplaceType is an enum of the valid extension marketplace configurations. -type ExtensionMarketplaceType string - -// ExtensionMarketplaceType enum. -const ( - ExtensionMarketplaceInternal ExtensionMarketplaceType = "internal" - ExtensionMarketplaceCustom ExtensionMarketplaceType = "custom" - ExtensionMarketplacePublic ExtensionMarketplaceType = "public" -) - -// MarketplaceExtensionPublicURL is the URL of the coder.com public marketplace that serves open source Code OSS extensions. -const MarketplaceExtensionPublicURL = "https://extensions.coder.com/api" - -// ConfigExtensionMarketplace describes the sitewide extension marketplace configuration. -type ConfigExtensionMarketplace struct { - URL string `json:"url"` - Type ExtensionMarketplaceType `json:"type"` -} - -// SiteConfigExtensionMarketplace fetches the extension marketplace configuration. -func (c *DefaultClient) SiteConfigExtensionMarketplace(ctx context.Context) (*ConfigExtensionMarketplace, error) { - var conf ConfigExtensionMarketplace - if err := c.requestBody(ctx, http.MethodGet, "/api/private/extensions/config", nil, &conf); err != nil { - return nil, err - } - return &conf, nil -} - -// PutSiteConfigExtensionMarketplace sets the extension marketplace configuration. -func (c *DefaultClient) PutSiteConfigExtensionMarketplace(ctx context.Context, req ConfigExtensionMarketplace) error { - return c.requestBody(ctx, http.MethodPut, "/api/private/extensions/config", req, nil) -} - -// ConfigWorkspaces is the site configuration for workspace attributes. -type ConfigWorkspaces struct { - GPUVendor string `json:"gpu_vendor,omitempty" valid:"in(nvidia|amd)"` - EnableContainerVMs bool `json:"enable_container_vms,omitempty"` - EnableWorkspacesAsCode bool `json:"enable_workspaces_as_code,omitempty"` - EnableP2P bool `json:"enable_p2p,omitempty"` -} - -// SiteConfigWorkspaces fetches the workspace configuration. -func (c *DefaultClient) SiteConfigWorkspaces(ctx context.Context) (*ConfigWorkspaces, error) { - var conf ConfigWorkspaces - // TODO: use the `/api/v0/workspaces/config route once we migrate from using general config - if err := c.requestBody(ctx, http.MethodGet, "/api/private/config", nil, &conf); err != nil { - return nil, err - } - return &conf, nil -} diff --git a/coder-sdk/devurl.go b/coder-sdk/devurl.go deleted file mode 100644 index af6cf64f..00000000 --- a/coder-sdk/devurl.go +++ /dev/null @@ -1,63 +0,0 @@ -package coder - -import ( - "context" - "fmt" - "net/http" -) - -// DevURL is the parsed json response record for a devURL from cemanager. -type DevURL struct { - ID string `json:"id" table:"-"` - URL string `json:"url" table:"URL"` - Port int `json:"port" table:"Port"` - Access string `json:"access" table:"Access"` - Name string `json:"name" table:"Name"` - Scheme string `json:"scheme" table:"Scheme"` -} - -type delDevURLRequest struct { - WorkspaceID string `json:"workspace_id"` - DevURLID string `json:"url_id"` -} - -// DeleteDevURL deletes the specified devurl. -func (c *DefaultClient) DeleteDevURL(ctx context.Context, workspaceID, urlID string) error { - reqURL := fmt.Sprintf("/api/v0/workspaces/%s/devurls/%s", workspaceID, urlID) - - return c.requestBody(ctx, http.MethodDelete, reqURL, delDevURLRequest{ - WorkspaceID: workspaceID, - DevURLID: urlID, - }, nil) -} - -// CreateDevURLReq defines the request parameters for creating a new DevURL. -type CreateDevURLReq struct { - WorkspaceID string `json:"workspace_id"` - Port int `json:"port"` - Access string `json:"access"` - Name string `json:"name"` - Scheme string `json:"scheme"` -} - -// CreateDevURL inserts a new dev URL for the authenticated user. -func (c *DefaultClient) CreateDevURL(ctx context.Context, workspaceID string, req CreateDevURLReq) error { - return c.requestBody(ctx, http.MethodPost, "/api/v0/workspaces/"+workspaceID+"/devurls", req, nil) -} - -// DevURLs fetches the Dev URLs for a given workspace. -func (c *DefaultClient) DevURLs(ctx context.Context, workspaceID string) ([]DevURL, error) { - var devurls []DevURL - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/workspaces/"+workspaceID+"/devurls", nil, &devurls); err != nil { - return nil, err - } - return devurls, nil -} - -// PutDevURLReq defines the request parameters for overwriting a DevURL. -type PutDevURLReq CreateDevURLReq - -// PutDevURL updates an existing devurl for the authenticated user. -func (c *DefaultClient) PutDevURL(ctx context.Context, workspaceID, urlID string, req PutDevURLReq) error { - return c.requestBody(ctx, http.MethodPut, "/api/v0/workspaces/"+workspaceID+"/devurls/"+urlID, req, nil) -} diff --git a/coder-sdk/doc.go b/coder-sdk/doc.go deleted file mode 100644 index 5fe3bd46..00000000 --- a/coder-sdk/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package coder provides simple APIs for integrating Go applications with Coder. -package coder diff --git a/coder-sdk/error.go b/coder-sdk/error.go deleted file mode 100644 index 9e1645d0..00000000 --- a/coder-sdk/error.go +++ /dev/null @@ -1,92 +0,0 @@ -package coder - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - - "golang.org/x/xerrors" -) - -// ErrNotFound describes an error case in which the requested resource could not be found. -var ErrNotFound = xerrors.New("resource not found") - -// ErrPermissions describes an error case in which the requester has insufficient permissions to access the requested resource. -var ErrPermissions = xerrors.New("insufficient permissions") - -// ErrAuthentication describes the error case in which the requester has invalid authentication. -var ErrAuthentication = xerrors.New("invalid authentication") - -// APIError is the expected payload format for API errors. -type APIError struct { - Err APIErrorMsg `json:"error"` -} - -// APIErrorMsg contains the rich error information returned by API errors. -type APIErrorMsg struct { - Msg string `json:"msg"` - Code string `json:"code"` - Details json.RawMessage `json:"details"` -} - -// NewHTTPError reads the response body and stores metadata -// about the response in order to be unpacked into -// an *APIError. -func NewHTTPError(resp *http.Response) *HTTPError { - var buf bytes.Buffer - _, err := io.Copy(&buf, resp.Body) - if err != nil { - return &HTTPError{ - cachedErr: err, - } - } - return &HTTPError{ - url: resp.Request.URL.String(), - statusCode: resp.StatusCode, - body: buf.Bytes(), - } -} - -// HTTPError represents an error from the Coder API. -type HTTPError struct { - url string - statusCode int - body []byte - cached *APIError - cachedErr error -} - -// Payload decode the response body into the standard error structure. The `details` -// section is stored as a raw json, and type depends on the `code` field. -func (e *HTTPError) Payload() (*APIError, error) { - var msg APIError - if e.cached != nil || e.cachedErr != nil { - return e.cached, e.cachedErr - } - - // Try to decode the payload as an error, if it fails or if there is no error message, - // return the response URL with the status. - if err := json.Unmarshal(e.body, &msg); err != nil { - e.cachedErr = err - return nil, err - } - - e.cached = &msg - return &msg, nil -} - -func (e *HTTPError) StatusCode() int { - return e.statusCode -} - -func (e *HTTPError) Error() string { - apiErr, err := e.Payload() - if err != nil || apiErr.Err.Msg == "" { - return fmt.Sprintf("%s: %d %s", e.url, e.statusCode, http.StatusText(e.statusCode)) - } - - // If the payload was a in the expected error format with a message, include it. - return apiErr.Err.Msg -} diff --git a/coder-sdk/image.go b/coder-sdk/image.go deleted file mode 100644 index c5101c87..00000000 --- a/coder-sdk/image.go +++ /dev/null @@ -1,101 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "net/url" - "time" -) - -// Image describes a Coder Image. -type Image struct { - ID string `json:"id" table:"-"` - OrganizationID string `json:"organization_id" table:"-"` - Repository string `json:"repository" table:"Repository"` - Description string `json:"description" table:"-"` - URL string `json:"url" table:"-"` // User-supplied URL for image. - Registry *Registry `json:"registry" table:"-"` - DefaultTag *ImageTag `json:"default_tag" table:"DefaultTag"` - DefaultCPUCores float32 `json:"default_cpu_cores" table:"DefaultCPUCores"` - DefaultMemoryGB float32 `json:"default_memory_gb" table:"DefaultMemoryGB"` - DefaultDiskGB int `json:"default_disk_gb" table:"DefaultDiskGB"` - Deprecated bool `json:"deprecated" table:"-"` - CreatedAt time.Time `json:"created_at" table:"-"` - UpdatedAt time.Time `json:"updated_at" table:"-"` -} - -// NewRegistryRequest describes a docker registry used in importing an image. -type NewRegistryRequest struct { - FriendlyName string `json:"friendly_name"` - Registry string `json:"registry"` - Username string `json:"username"` - Password string `json:"password"` -} - -// ImportImageReq is used to import new images and registries into Coder. -type ImportImageReq struct { - RegistryID *string `json:"registry_id"` // Used to import images to existing registries. - NewRegistry *NewRegistryRequest `json:"new_registry"` // Used when adding a new registry. - Repository string `json:"repository"` // Refers to the image. Ex: "codercom/ubuntu". - OrgID string `json:"org_id"` - Tag string `json:"tag"` - DefaultCPUCores float32 `json:"default_cpu_cores"` - DefaultMemoryGB int `json:"default_memory_gb"` - DefaultDiskGB int `json:"default_disk_gb"` - Description string `json:"description"` - URL string `json:"url"` -} - -// UpdateImageReq defines the requests parameters for a partial update of an image resource. -type UpdateImageReq struct { - DefaultCPUCores *float32 `json:"default_cpu_cores"` - DefaultMemoryGB *float32 `json:"default_memory_gb"` - DefaultDiskGB *int `json:"default_disk_gb"` - Description *string `json:"description"` - URL *string `json:"url"` - Deprecated *bool `json:"deprecated"` - DefaultTag *string `json:"default_tag"` -} - -// ImportImage creates a new image and optionally a new registry. -func (c *DefaultClient) ImportImage(ctx context.Context, req ImportImageReq) (*Image, error) { - var img Image - if err := c.requestBody(ctx, http.MethodPost, "/api/v0/images", req, &img); err != nil { - return nil, err - } - return &img, nil -} - -// ImageByID returns an image entity, fetched by its ID. -func (c *DefaultClient) ImageByID(ctx context.Context, id string) (*Image, error) { - var img Image - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/images/"+id, nil, &img); err != nil { - return nil, err - } - return &img, nil -} - -// OrganizationImages returns all of the images imported for orgID. -func (c *DefaultClient) OrganizationImages(ctx context.Context, orgID string) ([]Image, error) { - var ( - imgs []Image - query = url.Values{} - ) - - query.Set("org", orgID) - - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/images", nil, &imgs, withQueryParams(query)); err != nil { - return nil, err - } - return imgs, nil -} - -// UpdateImage applies a partial update to an image resource. -func (c *DefaultClient) UpdateImage(ctx context.Context, imageID string, req UpdateImageReq) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/images/"+imageID, req, nil) -} - -// UpdateImageTags refreshes the latest digests for all tags of the image. -func (c *DefaultClient) UpdateImageTags(ctx context.Context, imageID string) error { - return c.requestBody(ctx, http.MethodPost, "/api/v0/images/"+imageID+"/tags/update", nil, nil) -} diff --git a/coder-sdk/interface.go b/coder-sdk/interface.go deleted file mode 100644 index 6394b88b..00000000 --- a/coder-sdk/interface.go +++ /dev/null @@ -1,260 +0,0 @@ -package coder - -import ( - "context" - "net/url" - - "cdr.dev/wsep" - "github.com/pion/webrtc/v3" - "nhooyr.io/websocket" -) - -// Client wraps the Coder HTTP API. -// This is an interface to allow for mocking of coder-sdk client usage. -type Client interface { - // PushActivity pushes CLI activity to Coder. - PushActivity(ctx context.Context, source, workspaceID string) error - - // Me gets the details of the authenticated user. - Me(ctx context.Context) (*User, error) - - // UserByID get the details of a user by their id. - UserByID(ctx context.Context, id string) (*User, error) - - // SSHKey gets the current SSH kepair of the authenticated user. - SSHKey(ctx context.Context) (*SSHKey, error) - - // Users gets the list of user accounts. - Users(ctx context.Context) ([]User, error) - - // UserByEmail gets a user by email. - UserByEmail(ctx context.Context, email string) (*User, error) - - // UpdateUser applyes the partial update to the given user. - UpdateUser(ctx context.Context, userID string, req UpdateUserReq) error - - // UpdateUXState applies a partial update of the user's UX State. - UpdateUXState(ctx context.Context, userID string, uxsPartial map[string]interface{}) error - - // CreateUser creates a new user account. - CreateUser(ctx context.Context, req CreateUserReq) error - - // DeleteUser deletes a user account. - DeleteUser(ctx context.Context, userID string) error - - // SiteConfigAuth fetches the sitewide authentication configuration. - SiteConfigAuth(ctx context.Context) (*ConfigAuth, error) - - // PutSiteConfigAuth sets the sitewide authentication configuration. - PutSiteConfigAuth(ctx context.Context, req ConfigAuth) error - - // SiteConfigOAuth fetches the sitewide git provider OAuth configuration. - SiteConfigOAuth(ctx context.Context) (*ConfigOAuth, error) - - // PutSiteConfigOAuth sets the sitewide git provider OAuth configuration. - PutSiteConfigOAuth(ctx context.Context, req ConfigOAuth) error - - // SiteSetupModeEnabled fetches the current setup_mode state of a Coder deployment. - SiteSetupModeEnabled(ctx context.Context) (bool, error) - - // SiteConfigExtensionMarketplace fetches the extension marketplace configuration. - SiteConfigExtensionMarketplace(ctx context.Context) (*ConfigExtensionMarketplace, error) - - // PutSiteConfigExtensionMarketplace sets the extension marketplace configuration. - PutSiteConfigExtensionMarketplace(ctx context.Context, req ConfigExtensionMarketplace) error - - // SiteConfigWorkspaces fetches the workspace configuration. - SiteConfigWorkspaces(ctx context.Context) (*ConfigWorkspaces, error) - - // DeleteDevURL deletes the specified devurl. - DeleteDevURL(ctx context.Context, workspaceID, urlID string) error - - // CreateDevURL inserts a new devurl for the authenticated user. - CreateDevURL(ctx context.Context, workspaceID string, req CreateDevURLReq) error - - // DevURLs fetches the Dev URLs for a given workspace. - DevURLs(ctx context.Context, workspaceID string) ([]DevURL, error) - - // PutDevURL updates an existing devurl for the authenticated user. - PutDevURL(ctx context.Context, workspaceID, urlID string, req PutDevURLReq) error - - // CreateWorkspace sends a request to create a workspace. - CreateWorkspace(ctx context.Context, req CreateWorkspaceRequest) (*Workspace, error) - - // ParseTemplate parses a template config. It support both remote repositories and local files. - // If a local file is specified then all other values in the request are ignored. - ParseTemplate(ctx context.Context, req ParseTemplateRequest) (*TemplateVersion, error) - - // CreateWorkspaceFromRepo sends a request to create a workspace from a repository. - CreateWorkspaceFromRepo(ctx context.Context, orgID string, req TemplateVersion) (*Workspace, error) - - // Workspaces lists workspaces returned by the given filter. - Workspaces(ctx context.Context) ([]Workspace, error) - - // UserWorkspacesByOrganization gets the list of workspaces owned by the given user. - UserWorkspacesByOrganization(ctx context.Context, userID, orgID string) ([]Workspace, error) - - // DeleteWorkspace deletes the workspace. - DeleteWorkspace(ctx context.Context, workspaceID string) error - - // StopWorkspace stops the workspace. - StopWorkspace(ctx context.Context, workspaceID string) error - - // RebuildWorkspace requests that the given workspaceID is rebuilt with no changes to its specification. - RebuildWorkspace(ctx context.Context, workspaceID string) error - - // EditWorkspace modifies the workspace specification and initiates a rebuild. - EditWorkspace(ctx context.Context, workspaceID string, req UpdateWorkspaceReq) error - - // DialWsep dials a workspace's command execution interface - // See https://github.com/cdr/wsep for details. - DialWsep(ctx context.Context, baseURL *url.URL, workspaceID string) (*websocket.Conn, error) - - // DialExecutor gives a remote execution interface for performing commands inside a workspace. - DialExecutor(ctx context.Context, baseURL *url.URL, workspaceID string) (wsep.Execer, error) - - // DialIDEStatus opens a websocket connection for cpu load metrics on the workspace. - DialIDEStatus(ctx context.Context, baseURL *url.URL, workspaceID string) (*websocket.Conn, error) - - // DialWorkspaceBuildLog opens a websocket connection for the workspace build log messages. - DialWorkspaceBuildLog(ctx context.Context, workspaceID string) (*websocket.Conn, error) - - // FollowWorkspaceBuildLog trails the build log of a Coder workspace. - FollowWorkspaceBuildLog(ctx context.Context, workspaceID string) (<-chan BuildLogFollowMsg, error) - - // DialWorkspaceStats opens a websocket connection for workspace stats. - DialWorkspaceStats(ctx context.Context, workspaceID string) (*websocket.Conn, error) - - // DialResourceLoad opens a websocket connection for cpu load metrics on the workspace. - DialResourceLoad(ctx context.Context, workspaceID string) (*websocket.Conn, error) - - // WaitForWorkspaceReady will watch the build log and return when done. - WaitForWorkspaceReady(ctx context.Context, workspaceID string) error - - // WorkspaceByID get the details of a workspace by its id. - WorkspaceByID(ctx context.Context, id string) (*Workspace, error) - - // WorkspacesByWorkspaceProvider returns workspaces that belong to a particular workspace provider. - WorkspacesByWorkspaceProvider(ctx context.Context, wpID string) ([]Workspace, error) - - // ImportImage creates a new image and optionally a new registry. - ImportImage(ctx context.Context, req ImportImageReq) (*Image, error) - - // ImageByID returns an image entity, fetched by its ID. - ImageByID(ctx context.Context, id string) (*Image, error) - - // OrganizationImages returns all of the images imported for orgID. - OrganizationImages(ctx context.Context, orgID string) ([]Image, error) - - // UpdateImage applies a partial update to an image resource. - UpdateImage(ctx context.Context, imageID string, req UpdateImageReq) error - - // UpdateImageTags refreshes the latest digests for all tags of the image. - UpdateImageTags(ctx context.Context, imageID string) error - - // Organizations gets all Organizations. - Organizations(ctx context.Context) ([]Organization, error) - - // OrganizationByID get the Organization by its ID. - OrganizationByID(ctx context.Context, orgID string) (*Organization, error) - - // OrganizationMembers get all members of the given organization. - OrganizationMembers(ctx context.Context, orgID string) ([]OrganizationUser, error) - - // UpdateOrganization applys a partial update of an Organization resource. - UpdateOrganization(ctx context.Context, orgID string, req UpdateOrganizationReq) error - - // CreateOrganization creates a new Organization in Coder. - CreateOrganization(ctx context.Context, req CreateOrganizationReq) error - - // DeleteOrganization deletes an organization. - DeleteOrganization(ctx context.Context, orgID string) error - - // Registries fetches all registries in an organization. - Registries(ctx context.Context, orgID string) ([]Registry, error) - - // RegistryByID fetches a registry resource by its ID. - RegistryByID(ctx context.Context, registryID string) (*Registry, error) - - // UpdateRegistry applies a partial update to a registry resource. - UpdateRegistry(ctx context.Context, registryID string, req UpdateRegistryReq) error - - // DeleteRegistry deletes a registry resource by its ID. - DeleteRegistry(ctx context.Context, registryID string) error - - // CreateImageTag creates a new image tag resource. - CreateImageTag(ctx context.Context, imageID string, req CreateImageTagReq) (*ImageTag, error) - - // DeleteImageTag deletes an image tag resource. - DeleteImageTag(ctx context.Context, imageID, tag string) error - - // ImageTags fetch all image tags. - ImageTags(ctx context.Context, imageID string) ([]ImageTag, error) - - // ImageTagByID fetch an image tag by ID. - ImageTagByID(ctx context.Context, imageID, tagID string) (*ImageTag, error) - - // CreateAPIToken creates a new APIToken for making authenticated requests to Coder. - CreateAPIToken(ctx context.Context, userID string, req CreateAPITokenReq) (string, error) - - // APITokens fetches all APITokens owned by the given user. - APITokens(ctx context.Context, userID string) ([]APIToken, error) - - // APITokenByID fetches the metadata for a given APIToken. - APITokenByID(ctx context.Context, userID, tokenID string) (*APIToken, error) - - // DeleteAPIToken deletes an APIToken. - DeleteAPIToken(ctx context.Context, userID, tokenID string) error - - // RegenerateAPIToken regenerates the given APIToken and returns the new value. - RegenerateAPIToken(ctx context.Context, userID, tokenID string) (string, error) - - // APIVersion parses the coder-version http header from an authenticated request. - APIVersion(ctx context.Context) (string, error) - - // WorkspaceProviderByID fetches a workspace provider entity by its unique ID. - WorkspaceProviderByID(ctx context.Context, id string) (*KubernetesProvider, error) - - // WorkspaceProviders fetches all workspace providers known to the Coder control plane. - WorkspaceProviders(ctx context.Context) (*WorkspaceProviders, error) - - // CreateWorkspaceProvider creates a new WorkspaceProvider entity. - CreateWorkspaceProvider(ctx context.Context, req CreateWorkspaceProviderReq) (*CreateWorkspaceProviderRes, error) - - // DeleteWorkspaceProviderByID deletes a workspace provider entity from the Coder control plane. - DeleteWorkspaceProviderByID(ctx context.Context, id string) error - - // Token returns the API Token used to authenticate. - Token() string - - // BaseURL returns the BaseURL configured for this Client. - BaseURL() url.URL - - // CordonWorkspaceProvider prevents the provider from having any more workspaces placed on it. - CordonWorkspaceProvider(ctx context.Context, id, reason string) error - - // UnCordonWorkspaceProvider changes an existing cordoned providers status to 'Ready'; - // allowing it to continue creating new workspaces and provisioning resources for them. - UnCordonWorkspaceProvider(ctx context.Context, id string) error - - // RenameWorkspaceProvider changes an existing providers name field. - RenameWorkspaceProvider(ctx context.Context, id string, name string) error - - // SetPolicyTemplate sets the workspace policy template - SetPolicyTemplate(ctx context.Context, templateID string, templateScope TemplateScope, dryRun bool) (*SetPolicyTemplateResponse, error) - - // Satellites fetches all satellitess known to the Coder control plane. - Satellites(ctx context.Context) ([]Satellite, error) - - // CreateSatellite creates a new satellite entity. - CreateSatellite(ctx context.Context, req CreateSatelliteReq) (*Satellite, error) - - // DeleteSatelliteByID deletes a satellite entity from the Coder control plane. - DeleteSatelliteByID(ctx context.Context, id string) error - - // UpdateLastConnectionAt updates the last connection at attribute of a workspace. - UpdateLastConnectionAt(ctx context.Context, workspaceID string) error - - // ICEServers fetches the list of ICE servers advertised by the deployment. - ICEServers(ctx context.Context) ([]webrtc.ICEServer, error) -} diff --git a/coder-sdk/login.go b/coder-sdk/login.go deleted file mode 100644 index 6899df12..00000000 --- a/coder-sdk/login.go +++ /dev/null @@ -1,69 +0,0 @@ -package coder - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - "golang.org/x/xerrors" -) - -// LoginRequest is a request to authenticate using email -// and password credentials. -// -// This is provided for use in tests, and we recommend users authenticate -// using an API Token. -type LoginRequest struct { - Email string `json:"email"` - Password string `json:"password"` -} - -// LoginResponse contains successful response data for an authentication -// request, including an API Token to be used for subsequent requests. -// -// This is provided for use in tests, and we recommend users authenticate -// using an API Token. -type LoginResponse struct { - SessionToken string `json:"session_token"` -} - -// LoginWithPassword exchanges the email/password pair for -// a Session Token. -// -// If client is nil, the http.DefaultClient will be used. -func LoginWithPassword(ctx context.Context, client *http.Client, baseURL *url.URL, req *LoginRequest) (resp *LoginResponse, err error) { - if client == nil { - client = http.DefaultClient - } - - url := *baseURL - url.Path = fmt.Sprint(strings.TrimSuffix(url.Path, "/"), "/auth/basic/login") - - buf := &bytes.Buffer{} - err = json.NewEncoder(buf).Encode(req) - if err != nil { - return nil, xerrors.Errorf("failed to marshal JSON: %w", err) - } - - request, err := http.NewRequestWithContext(ctx, http.MethodPost, url.String(), buf) - if err != nil { - return nil, xerrors.Errorf("failed to create request: %w", err) - } - - response, err := client.Do(request) - if err != nil { - return nil, xerrors.Errorf("error processing login request: %w", err) - } - defer response.Body.Close() - - err = json.NewDecoder(response.Body).Decode(&resp) - if err != nil { - return nil, xerrors.Errorf("failed to decode response: %w", err) - } - - return resp, nil -} diff --git a/coder-sdk/org.go b/coder-sdk/org.go deleted file mode 100644 index 0922d229..00000000 --- a/coder-sdk/org.go +++ /dev/null @@ -1,101 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "time" -) - -// Organization describes an Organization in Coder. -type Organization struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Default bool `json:"default"` - Members []OrganizationUser `json:"members"` - WorkspaceCount int `json:"workspace_count"` - ResourceNamespace string `json:"resource_namespace"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - AutoOffThreshold Duration `json:"auto_off_threshold"` - CPUProvisioningRate float32 `json:"cpu_provisioning_rate"` - MemoryProvisioningRate float32 `json:"memory_provisioning_rate"` -} - -// OrganizationUser user wraps the basic User type and adds data specific to the user's membership of an organization. -type OrganizationUser struct { - User - OrganizationRoles []Role `json:"organization_roles"` - RolesUpdatedAt time.Time `json:"roles_updated_at"` -} - -// Organization Roles. -const ( - RoleOrgMember Role = "organization-member" - RoleOrgAdmin Role = "organization-admin" - RoleOrgManager Role = "organization-manager" -) - -// Organizations gets all Organizations. -func (c *DefaultClient) Organizations(ctx context.Context) ([]Organization, error) { - var orgs []Organization - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/orgs", nil, &orgs); err != nil { - return nil, err - } - return orgs, nil -} - -// OrganizationByID get the Organization by its ID. -func (c *DefaultClient) OrganizationByID(ctx context.Context, orgID string) (*Organization, error) { - var org Organization - err := c.requestBody(ctx, http.MethodGet, "/api/v0/orgs/"+orgID, nil, &org) - if err != nil { - return nil, err - } - return &org, nil -} - -// OrganizationMembers get all members of the given organization. -func (c *DefaultClient) OrganizationMembers(ctx context.Context, orgID string) ([]OrganizationUser, error) { - var members []OrganizationUser - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/orgs/"+orgID+"/members", nil, &members); err != nil { - return nil, err - } - return members, nil -} - -// UpdateOrganizationReq describes the patch request parameters to provide partial updates to an Organization resource. -type UpdateOrganizationReq struct { - Name *string `json:"name"` - Description *string `json:"description"` - Default *bool `json:"default"` - AutoOffThreshold *Duration `json:"auto_off_threshold"` - CPUProvisioningRate *float32 `json:"cpu_provisioning_rate"` - MemoryProvisioningRate *float32 `json:"memory_provisioning_rate"` -} - -// UpdateOrganization applys a partial update of an Organization resource. -func (c *DefaultClient) UpdateOrganization(ctx context.Context, orgID string, req UpdateOrganizationReq) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/orgs/"+orgID, req, nil) -} - -// CreateOrganizationReq describes the request parameters to create a new Organization. -type CreateOrganizationReq struct { - Name string `json:"name"` - Description string `json:"description"` - Default bool `json:"default"` - ResourceNamespace string `json:"resource_namespace"` - AutoOffThreshold Duration `json:"auto_off_threshold"` - CPUProvisioningRate float32 `json:"cpu_provisioning_rate"` - MemoryProvisioningRate float32 `json:"memory_provisioning_rate"` -} - -// CreateOrganization creates a new Organization in Coder. -func (c *DefaultClient) CreateOrganization(ctx context.Context, req CreateOrganizationReq) error { - return c.requestBody(ctx, http.MethodPost, "/api/v0/orgs", req, nil) -} - -// DeleteOrganization deletes an organization. -func (c *DefaultClient) DeleteOrganization(ctx context.Context, orgID string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/orgs/"+orgID, nil, nil) -} diff --git a/coder-sdk/registries.go b/coder-sdk/registries.go deleted file mode 100644 index 074155b3..00000000 --- a/coder-sdk/registries.go +++ /dev/null @@ -1,60 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "net/url" - "time" -) - -// Registry defines an image registry configuration. -type Registry struct { - ID string `json:"id"` - OrganizationID string `json:"organization_id"` - FriendlyName string `json:"friendly_name"` - Registry string `json:"registry"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Registries fetches all registries in an organization. -func (c *DefaultClient) Registries(ctx context.Context, orgID string) ([]Registry, error) { - var ( - r []Registry - query = url.Values{} - ) - - query.Set("org", orgID) - - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/registries", nil, &r, withQueryParams(query)); err != nil { - return nil, err - } - return r, nil -} - -// RegistryByID fetches a registry resource by its ID. -func (c *DefaultClient) RegistryByID(ctx context.Context, registryID string) (*Registry, error) { - var r Registry - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/registries/"+registryID, nil, &r); err != nil { - return nil, err - } - return &r, nil -} - -// UpdateRegistryReq defines the requests parameters for a partial update of a registry resource. -type UpdateRegistryReq struct { - Registry *string `json:"registry"` - FriendlyName *string `json:"friendly_name"` - Username *string `json:"username"` - Password *string `json:"password"` -} - -// UpdateRegistry applies a partial update to a registry resource. -func (c *DefaultClient) UpdateRegistry(ctx context.Context, registryID string, req UpdateRegistryReq) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/registries/"+registryID, req, nil) -} - -// DeleteRegistry deletes a registry resource by its ID. -func (c *DefaultClient) DeleteRegistry(ctx context.Context, registryID string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/registries/"+registryID, nil, nil) -} diff --git a/coder-sdk/request.go b/coder-sdk/request.go deleted file mode 100644 index d8f8bb76..00000000 --- a/coder-sdk/request.go +++ /dev/null @@ -1,127 +0,0 @@ -package coder - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - - "golang.org/x/xerrors" -) - -type requestOptions struct { - BaseURLOverride *url.URL - Query url.Values - Headers http.Header - Reader io.Reader -} - -type requestOption func(*requestOptions) - -// withQueryParams sets the provided query parameters on the request. -func withQueryParams(q url.Values) func(o *requestOptions) { - return func(o *requestOptions) { - o.Query = q - } -} - -func withHeaders(h http.Header) func(o *requestOptions) { - return func(o *requestOptions) { - o.Headers = h - } -} - -func withBaseURL(base *url.URL) func(o *requestOptions) { - return func(o *requestOptions) { - o.BaseURLOverride = base - } -} - -func withBody(w io.Reader) func(o *requestOptions) { - return func(o *requestOptions) { - o.Reader = w - } -} - -// request is a helper to set the cookie, marshal the payload and execute the request. -func (c *DefaultClient) request(ctx context.Context, method, path string, in interface{}, options ...requestOption) (*http.Response, error) { - url := *c.baseURL - - var config requestOptions - for _, o := range options { - o(&config) - } - if config.BaseURLOverride != nil { - url = *config.BaseURLOverride - } - if config.Query != nil { - url.RawQuery = config.Query.Encode() - } - url.Path = fmt.Sprint(strings.TrimSuffix(url.Path, "/"), path) - - // If we have incoming data, encode it as json. - var payload io.Reader - if in != nil { - body, err := json.Marshal(in) - if err != nil { - return nil, xerrors.Errorf("marshal request: %w", err) - } - payload = bytes.NewReader(body) - } - - if config.Reader != nil { - payload = config.Reader - } - - // Create the http request. - req, err := http.NewRequestWithContext(ctx, method, url.String(), payload) - if err != nil { - return nil, xerrors.Errorf("create request: %w", err) - } - - if config.Headers == nil { - req.Header = http.Header{} - } else { - req.Header = config.Headers - } - - // Provide the session token in a header - req.Header.Set("Session-Token", c.token) - - customAuthHeader, ok := os.LookupEnv("ENDPOINT_AUTH_HEADER") - if ok { - req.Header.Set("Authorization", customAuthHeader) - } - - // Execute the request. - return c.httpClient.Do(req) -} - -// requestBody is a helper extending the Client.request helper, checking the response code -// and decoding the response payload. -func (c *DefaultClient) requestBody(ctx context.Context, method, path string, in, out interface{}, opts ...requestOption) error { - resp, err := c.request(ctx, method, path, in, opts...) - if err != nil { - return xerrors.Errorf("Execute request: %q", err) - } - defer func() { _ = resp.Body.Close() }() // Best effort, likely connection dropped. - - // Responses in the 100 are handled by the http lib, in the 200 range, we have a success. - // Consider anything at or above 300 to be an error. - if resp.StatusCode > 299 { - return fmt.Errorf("unexpected status code %d: %w", resp.StatusCode, NewHTTPError(resp)) - } - - // If we expect a payload, process it as json. - if out != nil { - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return xerrors.Errorf("decode response body: %w", err) - } - } - return nil -} diff --git a/coder-sdk/satellite.go b/coder-sdk/satellite.go deleted file mode 100644 index 975ee32d..00000000 --- a/coder-sdk/satellite.go +++ /dev/null @@ -1,51 +0,0 @@ -package coder - -import ( - "context" - "net/http" -) - -type Satellite struct { - ID string `json:"id"` - Name string `json:"name"` - Fingerprint string `json:"fingerprint"` -} - -type satellites struct { - Data []Satellite `json:"data"` -} - -type createSatelliteResponse struct { - Data Satellite `json:"data"` -} - -// Satellites fetches all satellitess known to the Coder control plane. -func (c *DefaultClient) Satellites(ctx context.Context) ([]Satellite, error) { - var res satellites - err := c.requestBody(ctx, http.MethodGet, "/api/private/satellites", nil, &res) - if err != nil { - return nil, err - } - return res.Data, nil -} - -// CreateSatelliteReq defines the request parameters for creating a new satellite entity. -type CreateSatelliteReq struct { - Name string `json:"name"` - PublicKey string `json:"public_key"` -} - -// CreateSatellite creates a new satellite entity. -func (c *DefaultClient) CreateSatellite(ctx context.Context, req CreateSatelliteReq) (*Satellite, error) { - var res createSatelliteResponse - err := c.requestBody(ctx, http.MethodPost, "/api/private/satellites", req, &res) - if err != nil { - return nil, err - } - return &res.Data, nil -} - -// DeleteSatelliteByID deletes a satellite entity from the Coder control plane. -func (c *DefaultClient) DeleteSatelliteByID(ctx context.Context, id string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/private/satellites/"+id, nil, nil) -} diff --git a/coder-sdk/tags.go b/coder-sdk/tags.go deleted file mode 100644 index 9a3c941f..00000000 --- a/coder-sdk/tags.go +++ /dev/null @@ -1,72 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "time" -) - -// ImageTag is a Docker image tag. -type ImageTag struct { - ImageID string `json:"image_id" table:"-"` - Tag string `json:"tag" table:"Tag"` - LatestHash string `json:"latest_hash" table:"-"` - HashLastUpdatedAt time.Time `json:"hash_last_updated_at" table:"-"` - OSRelease *OSRelease `json:"os_release" table:"OS"` - Workspaces []*Workspace `json:"workspaces" table:"-"` - UpdatedAt time.Time `json:"updated_at" table:"UpdatedAt"` - CreatedAt time.Time `json:"created_at" table:"-"` -} - -func (i ImageTag) String() string { - return i.Tag -} - -// OSRelease is the marshalled /etc/os-release file. -type OSRelease struct { - ID string `json:"id"` - PrettyName string `json:"pretty_name"` - HomeURL string `json:"home_url"` -} - -func (o OSRelease) String() string { - return o.PrettyName -} - -// CreateImageTagReq defines the request parameters for creating a new image tag. -type CreateImageTagReq struct { - Tag string `json:"tag"` - Default bool `json:"default"` -} - -// CreateImageTag creates a new image tag resource. -func (c *DefaultClient) CreateImageTag(ctx context.Context, imageID string, req CreateImageTagReq) (*ImageTag, error) { - var tag ImageTag - if err := c.requestBody(ctx, http.MethodPost, "/api/v0/images/"+imageID+"/tags", req, tag); err != nil { - return nil, err - } - return &tag, nil -} - -// DeleteImageTag deletes an image tag resource. -func (c *DefaultClient) DeleteImageTag(ctx context.Context, imageID, tag string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/images/"+imageID+"/tags/"+tag, nil, nil) -} - -// ImageTags fetch all image tags. -func (c *DefaultClient) ImageTags(ctx context.Context, imageID string) ([]ImageTag, error) { - var tags []ImageTag - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/images/"+imageID+"/tags", nil, &tags); err != nil { - return nil, err - } - return tags, nil -} - -// ImageTagByID fetch an image tag by ID. -func (c *DefaultClient) ImageTagByID(ctx context.Context, imageID, tagID string) (*ImageTag, error) { - var tag ImageTag - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/images/"+imageID+"/tags/"+tagID, nil, &tag); err != nil { - return nil, err - } - return &tag, nil -} diff --git a/coder-sdk/tokens.go b/coder-sdk/tokens.go deleted file mode 100644 index dc12a173..00000000 --- a/coder-sdk/tokens.go +++ /dev/null @@ -1,67 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "time" -) - -// APIToken describes a Coder APIToken resource for use in API requests. -type APIToken struct { - ID string `json:"id"` - Name string `json:"name"` - Application bool `json:"application"` - UserID string `json:"user_id"` - LastUsed time.Time `json:"last_used"` -} - -// CreateAPITokenReq defines the paramemters for creating a new APIToken. -type CreateAPITokenReq struct { - Name string `json:"name"` -} - -type createAPITokenResp struct { - Key string `json:"key"` -} - -// CreateAPIToken creates a new APIToken for making authenticated requests to Coder. -func (c *DefaultClient) CreateAPIToken(ctx context.Context, userID string, req CreateAPITokenReq) (token string, _ error) { - var resp createAPITokenResp - err := c.requestBody(ctx, http.MethodPost, "/api/v0/api-keys/"+userID, req, &resp) - if err != nil { - return "", err - } - return resp.Key, nil -} - -// APITokens fetches all APITokens owned by the given user. -func (c *DefaultClient) APITokens(ctx context.Context, userID string) ([]APIToken, error) { - var tokens []APIToken - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/api-keys/"+userID, nil, &tokens); err != nil { - return nil, err - } - return tokens, nil -} - -// APITokenByID fetches the metadata for a given APIToken. -func (c *DefaultClient) APITokenByID(ctx context.Context, userID, tokenID string) (*APIToken, error) { - var token APIToken - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/api-keys/"+userID+"/"+tokenID, nil, &token); err != nil { - return nil, err - } - return &token, nil -} - -// DeleteAPIToken deletes an APIToken. -func (c *DefaultClient) DeleteAPIToken(ctx context.Context, userID, tokenID string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/api-keys/"+userID+"/"+tokenID, nil, nil) -} - -// RegenerateAPIToken regenerates the given APIToken and returns the new value. -func (c *DefaultClient) RegenerateAPIToken(ctx context.Context, userID, tokenID string) (token string, _ error) { - var resp createAPITokenResp - if err := c.requestBody(ctx, http.MethodPost, "/api/v0/api-keys/"+userID+"/"+tokenID+"/regen", nil, &resp); err != nil { - return "", err - } - return resp.Key, nil -} diff --git a/coder-sdk/users.go b/coder-sdk/users.go deleted file mode 100644 index 8b02f5f1..00000000 --- a/coder-sdk/users.go +++ /dev/null @@ -1,165 +0,0 @@ -package coder - -import ( - "context" - "net/http" - "time" -) - -// User describes a Coder user account. -type User struct { - ID string `json:"id" table:"-"` - Email string `json:"email" table:"Email"` - Username string `json:"username" table:"Username"` - Name string `json:"name" table:"Name"` - Roles []Role `json:"roles" table:"-"` - TemporaryPassword bool `json:"temporary_password" table:"-"` - LoginType string `json:"login_type" table:"-"` - KeyRegeneratedAt time.Time `json:"key_regenerated_at" table:"-"` - CreatedAt time.Time `json:"created_at" table:"CreatedAt"` - UpdatedAt time.Time `json:"updated_at" table:"-"` -} - -// Role defines a Coder permissions role group. -type Role string - -// Site Roles. -const ( - SiteAdmin Role = "site-admin" - SiteAuditor Role = "site-auditor" - SiteManager Role = "site-manager" - SiteMember Role = "site-member" -) - -// LoginType defines the enum of valid user login types. -type LoginType string - -// LoginType enum options. -const ( - LoginTypeBuiltIn LoginType = "built-in" - LoginTypeSAML LoginType = "saml" - LoginTypeOIDC LoginType = "oidc" -) - -// Me gets the details of the authenticated user. -func (c *DefaultClient) Me(ctx context.Context) (*User, error) { - return c.UserByID(ctx, Me) -} - -// UserByID get the details of a user by their id. -func (c *DefaultClient) UserByID(ctx context.Context, id string) (*User, error) { - var u User - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/users/"+id, nil, &u); err != nil { - return nil, err - } - 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 *DefaultClient) SSHKey(ctx context.Context) (*SSHKey, error) { - var key SSHKey - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/users/me/sshkey", nil, &key); err != nil { - return nil, err - } - return &key, nil -} - -// Users gets the list of user accounts. -func (c *DefaultClient) Users(ctx context.Context) ([]User, error) { - var u []User - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/users", nil, &u); err != nil { - return nil, err - } - return u, nil -} - -// UserByEmail gets a user by email. -func (c *DefaultClient) UserByEmail(ctx context.Context, email string) (*User, error) { - if email == Me { - return c.Me(ctx) - } - users, err := c.Users(ctx) - if err != nil { - return nil, err - } - for _, u := range users { - if u.Email == email { - return &u, nil - } - } - return nil, ErrNotFound -} - -// UpdateUserReq defines a modification to the user, updating the -// value of all non-nil values. -type UpdateUserReq struct { - *UserPasswordSettings - Revoked *bool `json:"revoked,omitempty"` - Roles *[]Role `json:"roles,omitempty"` - LoginType *LoginType `json:"login_type,omitempty"` - Name *string `json:"name,omitempty"` - Username *string `json:"username,omitempty"` - Email *string `json:"email,omitempty"` - DotfilesGitURL *string `json:"dotfiles_git_uri,omitempty"` -} - -// UserPasswordSettings allows modification of the user's password -// settings. -// -// These settings are only applicable to users managed using the -// built-in authentication provider; users authenticating using -// OAuth must change their password through the identity provider -// instead. -type UserPasswordSettings struct { - // OldPassword is the account's current password. - OldPassword string `json:"old_password,omitempty"` - - // Password is the new password, which may be a temporary password. - Password string `json:"password,omitempty"` - - // Temporary indicates that API access should be restricted to the - // password change API and a few other APIs. If set to true, Coder - // will prompt the user to change their password upon their next - // login through the web interface. - Temporary bool `json:"temporary_password,omitempty"` -} - -// UpdateUser applyes the partial update to the given user. -func (c *DefaultClient) UpdateUser(ctx context.Context, userID string, req UpdateUserReq) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/users/"+userID, req, nil) -} - -// UpdateUXState applies a partial update of the user's UX State. -func (c *DefaultClient) UpdateUXState(ctx context.Context, userID string, uxsPartial map[string]interface{}) error { - if err := c.requestBody(ctx, http.MethodPut, "/api/private/users/"+userID+"/ux-state", uxsPartial, nil); err != nil { - return err - } - return nil -} - -// CreateUserReq defines the request parameters for creating a new user resource. -type CreateUserReq struct { - Name string `json:"name"` - Username string `json:"username"` - Email string `json:"email"` - Password string `json:"password"` - TemporaryPassword bool `json:"temporary_password"` - LoginType LoginType `json:"login_type"` - OrganizationsIDs []string `json:"organizations"` -} - -// CreateUser creates a new user account. -func (c *DefaultClient) CreateUser(ctx context.Context, req CreateUserReq) error { - return c.requestBody(ctx, http.MethodPost, "/api/v0/users", req, nil) -} - -// DeleteUser deletes a user account. -func (c *DefaultClient) DeleteUser(ctx context.Context, userID string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/users/"+userID, nil, nil) -} diff --git a/coder-sdk/users_test.go b/coder-sdk/users_test.go deleted file mode 100644 index 31240880..00000000 --- a/coder-sdk/users_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package coder_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -func TestUsers(t *testing.T) { - t.Parallel() - - const username = "root" - const name = "Charlie Root" - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Users is a GET", http.MethodGet, r.Method) - assert.Equal(t, "Path matches", "/api/v0/users", r.URL.Path) - - users := []map[string]interface{}{ - { - "id": "default", - "email": "root@user.com", - "username": username, - "name": name, - "roles": []coder.Role{coder.SiteAdmin}, - "temporary_password": false, - "login_type": coder.LoginTypeBuiltIn, - "key_regenerated_at": time.Now(), - "created_at": time.Now(), - "updated_at": time.Now(), - }, - } - - w.WriteHeader(http.StatusOK) - err := json.NewEncoder(w).Encode(users) - assert.Success(t, "error encoding JSON", err) - })) - t.Cleanup(func() { - server.Close() - }) - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Token: "JcmErkJjju-KSrztst0IJX7xGJhKQPtfv", - }) - assert.Success(t, "failed to create coder.Client", err) - - users, err := client.Users(context.Background()) - assert.Success(t, "error getting Users", err) - assert.True(t, "users should return a single user", len(users) == 1) - assert.Equal(t, "expected user's name to match", name, users[0].Name) - assert.Equal(t, "expected user's username to match", username, users[0].Username) -} - -func TestUserUpdatePassword(t *testing.T) { - t.Parallel() - - const oldPassword = "vt9g9rxsptrq" - const newPassword = "wmf39jw2f7pk" - - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - assert.Equal(t, "Users is a PATCH", http.MethodPatch, r.Method) - assert.Equal(t, "Path matches", "/api/v0/users/me", r.URL.Path) - - expected := map[string]interface{}{ - "old_password": oldPassword, - "password": newPassword, - } - var request map[string]interface{} - err := json.NewDecoder(r.Body).Decode(&request) - assert.Success(t, "error decoding JSON", err) - assert.Equal(t, "unexpected request data", expected, request) - - w.WriteHeader(http.StatusOK) - })) - t.Cleanup(func() { - server.Close() - }) - - u, err := url.Parse(server.URL) - assert.Success(t, "failed to parse test server URL", err) - - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - HTTPClient: server.Client(), - Token: "JcmErkJjju-KSrztst0IJX7xGJhKQPtfv", - }) - assert.Success(t, "failed to create coder.Client", err) - - err = client.UpdateUser(context.Background(), "me", coder.UpdateUserReq{ - UserPasswordSettings: &coder.UserPasswordSettings{ - OldPassword: oldPassword, - Password: newPassword, - Temporary: false, - }, - }) - assert.Success(t, "error when updating password", err) -} diff --git a/coder-sdk/util.go b/coder-sdk/util.go deleted file mode 100644 index 0abba3c9..00000000 --- a/coder-sdk/util.go +++ /dev/null @@ -1,36 +0,0 @@ -package coder - -import ( - "encoding/json" - "strconv" - "time" -) - -// String gives a string pointer. -func String(s string) *string { - return &s -} - -// Duration is a time.Duration wrapper that marshals to millisecond precision. -// While it looses precision, most javascript applications expect durations to be in milliseconds. -type Duration time.Duration - -// MarshalJSON marshals the duration to millisecond precision. -func (d Duration) MarshalJSON() ([]byte, error) { - du := time.Duration(d) - return json.Marshal(du.Milliseconds()) -} - -// UnmarshalJSON unmarshals a millisecond-precision integer to -// a time.Duration. -func (d *Duration) UnmarshalJSON(b []byte) error { - i, err := strconv.ParseInt(string(b), 10, 64) - if err != nil { - return err - } - - *d = Duration(time.Duration(i) * time.Millisecond) - return nil -} - -func (d Duration) String() string { return time.Duration(d).String() } diff --git a/coder-sdk/version.go b/coder-sdk/version.go deleted file mode 100644 index dd1ae9ec..00000000 --- a/coder-sdk/version.go +++ /dev/null @@ -1,23 +0,0 @@ -package coder - -import ( - "context" - "net/http" -) - -// APIVersion parses the coder-version http header from an authenticated request. -func (c *DefaultClient) APIVersion(ctx context.Context) (string, error) { - const coderVersionHeaderKey = "coder-version" - resp, err := c.request(ctx, http.MethodGet, "/api", nil) - if err != nil { - return "", err - } - defer resp.Body.Close() - - version := resp.Header.Get(coderVersionHeaderKey) - if version == "" { - version = "unknown" - } - - return version, nil -} diff --git a/coder-sdk/webrtc.go b/coder-sdk/webrtc.go deleted file mode 100644 index 4ea1713c..00000000 --- a/coder-sdk/webrtc.go +++ /dev/null @@ -1,23 +0,0 @@ -package coder - -import ( - "context" - "net/http" - - "github.com/pion/webrtc/v3" -) - -type getICEServersRes struct { - Data []webrtc.ICEServer `json:"data"` -} - -// ICEServers fetches the list of ICE servers advertised by the deployment. -func (c *DefaultClient) ICEServers(ctx context.Context) ([]webrtc.ICEServer, error) { - var res getICEServersRes - err := c.requestBody(ctx, http.MethodGet, "/api/private/webrtc/ice", nil, &res) - if err != nil { - return nil, err - } - - return res.Data, nil -} diff --git a/coder-sdk/workspace.go b/coder-sdk/workspace.go deleted file mode 100644 index b6071b5a..00000000 --- a/coder-sdk/workspace.go +++ /dev/null @@ -1,511 +0,0 @@ -package coder - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "cdr.dev/wsep" - "golang.org/x/xerrors" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" -) - -// Workspace describes a Coder workspace. -type Workspace struct { - ID string `json:"id" table:"-"` - Name string `json:"name" table:"Name"` - ImageID string `json:"image_id" table:"-"` - ImageTag string `json:"image_tag" table:"ImageTag"` - OrganizationID string `json:"organization_id" table:"-"` - UserID string `json:"user_id" table:"-"` - LastBuiltAt time.Time `json:"last_built_at" table:"-"` - CPUCores float32 `json:"cpu_cores" table:"CPUCores"` - MemoryGB float32 `json:"memory_gb" table:"MemoryGB"` - DiskGB int `json:"disk_gb" table:"DiskGB"` - GPUs int `json:"gpus" table:"-"` - Updating bool `json:"updating" table:"-"` - LatestStat WorkspaceStat `json:"latest_stat" table:"Status"` - RebuildMessages []RebuildMessage `json:"rebuild_messages" table:"-"` - CreatedAt time.Time `json:"created_at" table:"-"` - UpdatedAt time.Time `json:"updated_at" table:"-"` - LastOpenedAt time.Time `json:"last_opened_at" table:"-"` - LastConnectionAt time.Time `json:"last_connection_at" table:"-"` - AutoOffThreshold Duration `json:"auto_off_threshold" table:"-"` - UseContainerVM bool `json:"use_container_vm" table:"CVM"` - ResourcePoolID string `json:"resource_pool_id" table:"-"` -} - -// RebuildMessage defines the message shown when a Workspace requires a rebuild for it can be accessed. -type RebuildMessage struct { - Text string `json:"text"` - Required bool `json:"required"` - AutoOffThreshold Duration `json:"auto_off_threshold"` -} - -// WorkspaceStat represents the state of a workspace. -type WorkspaceStat struct { - Time time.Time `json:"time"` - LastOnline time.Time `json:"last_online"` - ContainerStatus WorkspaceStatus `json:"container_status"` - StatError string `json:"stat_error"` - CPUUsage float32 `json:"cpu_usage"` - MemoryTotal int64 `json:"memory_total"` - MemoryUsage float32 `json:"memory_usage"` - DiskTotal int64 `json:"disk_total"` - DiskUsed int64 `json:"disk_used"` -} - -func (e WorkspaceStat) String() string { return string(e.ContainerStatus) } - -// WorkspaceStatus refers to the states of a workspace. -type WorkspaceStatus string - -// The following represent the possible workspace container states. -const ( - WorkspaceCreating WorkspaceStatus = "CREATING" - WorkspaceOff WorkspaceStatus = "OFF" - WorkspaceOn WorkspaceStatus = "ON" - WorkspaceFailed WorkspaceStatus = "FAILED" - WorkspaceUnknown WorkspaceStatus = "UNKNOWN" -) - -// CreateWorkspaceRequest is used to configure a new workspace. -type CreateWorkspaceRequest struct { - Name string `json:"name"` - ImageID string `json:"image_id"` - OrgID string `json:"org_id"` - ImageTag string `json:"image_tag"` - CPUCores float32 `json:"cpu_cores"` - MemoryGB float32 `json:"memory_gb"` - DiskGB int `json:"disk_gb"` - GPUs int `json:"gpus"` - UseContainerVM bool `json:"use_container_vm"` - ResourcePoolID string `json:"resource_pool_id"` - Namespace string `json:"namespace"` - EnableAutoStart bool `json:"autostart_enabled"` - - // TemplateID comes from the parse template route on cemanager. - TemplateID string `json:"template_id,omitempty"` -} - -// CreateWorkspace sends a request to create a workspace. -func (c *DefaultClient) CreateWorkspace(ctx context.Context, req CreateWorkspaceRequest) (*Workspace, error) { - var workspace Workspace - if err := c.requestBody(ctx, http.MethodPost, "/api/v0/workspaces", req, &workspace); err != nil { - return nil, err - } - return &workspace, nil -} - -// ParseTemplateRequest parses a template. If Local is a non-nil reader -// it will obviate any other fields on the request. -type ParseTemplateRequest struct { - RepoURL string `json:"repo_url"` - Ref string `json:"ref"` - Filepath string `json:"filepath"` - OrgID string `json:"-"` - Local io.Reader `json:"-"` -} - -// TemplateVersion is a Workspaces As Code (WAC) template. -// For now, let's not interpret it on the CLI level. We just need -// to forward this as part of the create workspace request. -type TemplateVersion struct { - ID string `json:"id"` - TemplateID string `json:"template_id"` - // FileHash is the sha256 hash of the template's file contents. - FileHash string `json:"file_hash"` - // Commit is the git commit from which the template was derived. - Commit string `json:"commit"` - CommitMessage string `json:"commit_message"` - CreatedAt time.Time `json:"created_at"` -} - -// ParseTemplate parses a template config. It support both remote repositories and local files. -// If a local file is specified then all other values in the request are ignored. -func (c *DefaultClient) ParseTemplate(ctx context.Context, req ParseTemplateRequest) (*TemplateVersion, error) { - const path = "/api/private/workspaces/template/parse" - var ( - tpl TemplateVersion - opts []requestOption - headers = http.Header{} - query = url.Values{} - ) - - query.Set("org-id", req.OrgID) - - opts = append(opts, withQueryParams(query)) - - if req.Local == nil { - if err := c.requestBody(ctx, http.MethodPost, path, req, &tpl, opts...); err != nil { - return &tpl, err - } - return &tpl, nil - } - - headers.Set("Content-Type", "application/octet-stream") - opts = append(opts, withBody(req.Local), withHeaders(headers)) - - err := c.requestBody(ctx, http.MethodPost, path, nil, &tpl, opts...) - if err != nil { - return &tpl, err - } - - return &tpl, nil -} - -// CreateWorkspaceFromRepo sends a request to create a workspace from a repository. -func (c *DefaultClient) CreateWorkspaceFromRepo(ctx context.Context, orgID string, req TemplateVersion) (*Workspace, error) { - var workspace Workspace - if err := c.requestBody(ctx, http.MethodPost, "/api/private/orgs/"+orgID+"/workspaces/from-repo", req, &workspace); err != nil { - return nil, err - } - return &workspace, nil -} - -// Workspaces lists workspaces returned by the given filter. -// TODO: add the filter options, explore performance issue. -func (c *DefaultClient) Workspaces(ctx context.Context) ([]Workspace, error) { - var workspaces []Workspace - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/workspaces", nil, &workspaces); err != nil { - return nil, err - } - return workspaces, nil -} - -// UserWorkspacesByOrganization gets the list of workspaces owned by the given user. -func (c *DefaultClient) UserWorkspacesByOrganization(ctx context.Context, userID, orgID string) ([]Workspace, error) { - var ( - workspaces []Workspace - query = url.Values{} - ) - - query.Add("orgs", orgID) - query.Add("users", userID) - - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/workspaces", nil, &workspaces, withQueryParams(query)); err != nil { - return nil, err - } - return workspaces, nil -} - -// DeleteWorkspace deletes the workspace. -func (c *DefaultClient) DeleteWorkspace(ctx context.Context, workspaceID string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/v0/workspaces/"+workspaceID, nil, nil) -} - -// StopWorkspace stops the workspace. -func (c *DefaultClient) StopWorkspace(ctx context.Context, workspaceID string) error { - return c.requestBody(ctx, http.MethodPut, "/api/v0/workspaces/"+workspaceID+"/stop", nil, nil) -} - -// UpdateWorkspaceReq defines the update operation, only setting -// nil-fields. -type UpdateWorkspaceReq struct { - ImageID *string `json:"image_id"` - ImageTag *string `json:"image_tag"` - CPUCores *float32 `json:"cpu_cores"` - MemoryGB *float32 `json:"memory_gb"` - DiskGB *int `json:"disk_gb"` - GPUs *int `json:"gpus"` - TemplateID *string `json:"template_id"` -} - -// RebuildWorkspace requests that the given workspaceID is rebuilt with no changes to its specification. -func (c *DefaultClient) RebuildWorkspace(ctx context.Context, workspaceID string) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/workspaces/"+workspaceID, UpdateWorkspaceReq{}, nil) -} - -// EditWorkspace modifies the workspace specification and initiates a rebuild. -func (c *DefaultClient) EditWorkspace(ctx context.Context, workspaceID string, req UpdateWorkspaceReq) error { - return c.requestBody(ctx, http.MethodPatch, "/api/v0/workspaces/"+workspaceID, req, nil) -} - -// DialWsep dials a workspace's command execution interface -// See https://github.com/cdr/wsep for details. -func (c *DefaultClient) DialWsep(ctx context.Context, baseURL *url.URL, workspaceID string) (*websocket.Conn, error) { - return c.dialWebsocket(ctx, "/proxy/workspaces/"+workspaceID+"/wsep", withBaseURL(baseURL)) -} - -// DialExecutor gives a remote execution interface for performing commands -// inside a workspace. -func (c *DefaultClient) DialExecutor(ctx context.Context, baseURL *url.URL, workspaceID string) (wsep.Execer, error) { - ws, err := c.DialWsep(ctx, baseURL, workspaceID) - if err != nil { - return nil, err - } - return wsep.RemoteExecer(ws), nil -} - -// DialIDEStatus opens a websocket connection for cpu load metrics on the workspace. -func (c *DefaultClient) DialIDEStatus(ctx context.Context, baseURL *url.URL, workspaceID string) (*websocket.Conn, error) { - return c.dialWebsocket(ctx, "/proxy/workspaces/"+workspaceID+"/ide/api/status", withBaseURL(baseURL)) -} - -// DialWorkspaceBuildLog opens a websocket connection for the workspace build log messages. -func (c *DefaultClient) DialWorkspaceBuildLog(ctx context.Context, workspaceID string) (*websocket.Conn, error) { - return c.dialWebsocket(ctx, "/api/private/workspaces/"+workspaceID+"/watch-update") -} - -// BuildLog defines a build log record for a Coder workspace. -type BuildLog struct { - ID string `db:"id" json:"id"` - WorkspaceID string `db:"workspace_id" json:"workspace_id"` - // BuildID allows the frontend to separate the logs from the old build with the logs from the new. - BuildID string `db:"build_id" json:"build_id"` - Time time.Time `db:"time" json:"time"` - Type BuildLogType `db:"type" json:"type"` - Msg string `db:"msg" json:"msg"` -} - -// BuildLogFollowMsg wraps the base BuildLog and adds a field for collecting -// errors that may occur when follow or parsing. -type BuildLogFollowMsg struct { - BuildLog - Err error -} - -// FollowWorkspaceBuildLog trails the build log of a Coder workspace. -func (c *DefaultClient) FollowWorkspaceBuildLog(ctx context.Context, workspaceID string) (<-chan BuildLogFollowMsg, error) { - ch := make(chan BuildLogFollowMsg) - ws, err := c.DialWorkspaceBuildLog(ctx, workspaceID) - if err != nil { - return nil, err - } - go func() { - defer ws.Close(websocket.StatusNormalClosure, "normal closure") - defer close(ch) - for { - var msg BuildLog - if err := wsjson.Read(ctx, ws, &msg); err != nil { - ch <- BuildLogFollowMsg{Err: err} - if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { - return - } - continue - } - ch <- BuildLogFollowMsg{BuildLog: msg} - } - }() - return ch, nil -} - -// DialWorkspaceStats opens a websocket connection for workspace stats. -func (c *DefaultClient) DialWorkspaceStats(ctx context.Context, workspaceID string) (*websocket.Conn, error) { - return c.dialWebsocket(ctx, "/api/private/workspaces/"+workspaceID+"/watch-stats") -} - -// DialResourceLoad opens a websocket connection for cpu load metrics on the workspace. -func (c *DefaultClient) DialResourceLoad(ctx context.Context, workspaceID string) (*websocket.Conn, error) { - return c.dialWebsocket(ctx, "/api/private/workspaces/"+workspaceID+"/watch-resource-load") -} - -// BuildLogType describes the type of an event. -type BuildLogType string - -const ( - // BuildLogTypeStart signals that a new build log has begun. - BuildLogTypeStart BuildLogType = "start" - // BuildLogTypeStage is a stage-level event for a workspace. - // It can be thought of as a major step in the workspace's - // lifecycle. - BuildLogTypeStage BuildLogType = "stage" - // BuildLogTypeError describes an error that has occurred. - BuildLogTypeError BuildLogType = "error" - // BuildLogTypeSubstage describes a subevent that occurs as - // part of a stage. This can be the output from a user's - // personalization script, or a long running command. - BuildLogTypeSubstage BuildLogType = "substage" - // BuildLogTypeDone signals that the build has completed. - BuildLogTypeDone BuildLogType = "done" -) - -type buildLogMsg struct { - Type BuildLogType `json:"type"` -} - -// WaitForWorkspaceReady will watch the build log and return when done. -func (c *DefaultClient) WaitForWorkspaceReady(ctx context.Context, workspaceID string) error { - conn, err := c.DialWorkspaceBuildLog(ctx, workspaceID) - if err != nil { - return xerrors.Errorf("%s: dial build log: %w", workspaceID, err) - } - - for { - msg := buildLogMsg{} - err := wsjson.Read(ctx, conn, &msg) - if err != nil { - return xerrors.Errorf("%s: reading build log msg: %w", workspaceID, err) - } - - if msg.Type == BuildLogTypeDone { - return nil - } - } -} - -// WorkspaceByID get the details of a workspace by its id. -func (c *DefaultClient) WorkspaceByID(ctx context.Context, id string) (*Workspace, error) { - var workspace Workspace - if err := c.requestBody(ctx, http.MethodGet, "/api/v0/workspaces/"+id, nil, &workspace); err != nil { - return nil, err - } - return &workspace, nil -} - -// WorkspacesByWorkspaceProvider returns all workspaces that belong to a particular workspace provider. -func (c *DefaultClient) WorkspacesByWorkspaceProvider(ctx context.Context, wpID string) ([]Workspace, error) { - var workspaces []Workspace - if err := c.requestBody(ctx, http.MethodGet, "/api/private/resource-pools/"+wpID+"/workspaces", nil, &workspaces); err != nil { - return nil, err - } - return workspaces, nil -} - -const ( - // SkipTemplateOrg allows skipping checks on organizations. - SkipTemplateOrg = "SKIP_ORG" -) - -type TemplateScope string - -const ( - // TemplateScopeSite is the scope for a site wide policy template. - TemplateScopeSite = "site" -) - -type SetPolicyTemplateRequest struct { - TemplateID string `json:"template_id"` - Type string `json:"type"` // site, org -} - -type SetPolicyTemplateResponse struct { - MergeConflicts []*WorkspaceTemplateMergeConflict `json:"merge_conflicts"` -} - -type WorkspaceTemplateMergeConflict struct { - WorkspaceID string `json:"workspace_id"` - CurrentTemplateWarnings []string `json:"current_template_warnings"` - CurrentTemplateError *TplError `json:"current_template_errors"` - LatestTemplateWarnings []string `json:"latest_template_warnings"` - LatestTemplateError *TplError `json:"latest_template_errors"` - CurrentTemplateIsLatest bool `json:"current_template_is_latest"` - Message string `json:"message"` -} - -func (mc WorkspaceTemplateMergeConflict) String() string { - var sb strings.Builder - - if mc.Message != "" { - sb.WriteString(mc.Message) - } - - currentConflicts := len(mc.CurrentTemplateWarnings) != 0 || mc.CurrentTemplateError != nil - updateConflicts := len(mc.LatestTemplateWarnings) != 0 || mc.LatestTemplateError != nil - - if !currentConflicts && !updateConflicts { - sb.WriteString("No workspace conflicts\n") - return sb.String() - } - - if currentConflicts { - if len(mc.CurrentTemplateWarnings) != 0 { - fmt.Fprintf(&sb, "Warnings: \n%s\n", strings.Join(mc.CurrentTemplateWarnings, "\n")) - } - if mc.CurrentTemplateError != nil { - fmt.Fprintf(&sb, "Errors: \n%s\n", strings.Join(mc.CurrentTemplateError.Msgs, "\n")) - } - } - - if !mc.CurrentTemplateIsLatest && updateConflicts { - sb.WriteString("If workspace is updated to the latest template:\n") - if len(mc.LatestTemplateWarnings) != 0 { - fmt.Fprintf(&sb, "Warnings: \n%s\n", strings.Join(mc.LatestTemplateWarnings, "\n")) - } - if mc.LatestTemplateError != nil { - fmt.Fprintf(&sb, "Errors: \n%s\n", strings.Join(mc.LatestTemplateError.Msgs, "\n")) - } - } - - return sb.String() -} - -type WorkspaceTemplateMergeConflicts []*WorkspaceTemplateMergeConflict - -func (mcs WorkspaceTemplateMergeConflicts) Summary() string { - var ( - sb strings.Builder - currentWarnings int - updateWarnings int - currentErrors int - updateErrors int - ) - - for _, mc := range mcs { - if len(mc.CurrentTemplateWarnings) != 0 { - currentWarnings++ - } - if len(mc.LatestTemplateWarnings) != 0 { - updateWarnings++ - } - if mc.CurrentTemplateError != nil { - currentErrors++ - } - if mc.LatestTemplateError != nil { - updateErrors++ - } - } - - if currentErrors == 0 && updateErrors == 0 && currentWarnings == 0 && updateWarnings == 0 { - sb.WriteString("No workspace conflicts\n") - return sb.String() - } - - if currentErrors != 0 { - fmt.Fprintf(&sb, "%d workspaces will not be able to be rebuilt\n", currentErrors) - } - if updateErrors != 0 { - fmt.Fprintf(&sb, "%d workspaces will not be able to be rebuilt if updated to the latest version\n", updateErrors) - } - if currentWarnings != 0 { - fmt.Fprintf(&sb, "%d workspaces will be impacted\n", currentWarnings) - } - if updateWarnings != 0 { - fmt.Fprintf(&sb, "%d workspaces will be impacted if updated to the latest version\n", updateWarnings) - } - - return sb.String() -} - -type TplError struct { - // Msgs are the human facing strings to present to the user. Since there can be multiple - // problems with a template, there might be multiple strings - Msgs []string `json:"messages"` -} - -func (c *DefaultClient) SetPolicyTemplate(ctx context.Context, templateID string, templateScope TemplateScope, dryRun bool) (*SetPolicyTemplateResponse, error) { - var ( - resp SetPolicyTemplateResponse - query = url.Values{} - ) - - req := SetPolicyTemplateRequest{ - TemplateID: templateID, - Type: string(templateScope), - } - - if dryRun { - query.Set("dry-run", "true") - } - - if err := c.requestBody(ctx, http.MethodPost, "/api/private/workspaces/template/policy", req, &resp, withQueryParams(query)); err != nil { - return nil, err - } - - return &resp, nil -} diff --git a/coder-sdk/workspace_providers.go b/coder-sdk/workspace_providers.go deleted file mode 100644 index 1ed0589c..00000000 --- a/coder-sdk/workspace_providers.go +++ /dev/null @@ -1,140 +0,0 @@ -package coder - -import ( - "context" - "net/http" -) - -// WorkspaceProviders defines all available Coder workspace provider targets. -type WorkspaceProviders struct { - Kubernetes []KubernetesProvider `json:"kubernetes"` -} - -// KubernetesProvider defines an entity capable of deploying and acting as an ingress for Coder workspaces. -type KubernetesProvider struct { - ID string `json:"id" table:"-"` - Name string `json:"name" table:"Name"` - Status WorkspaceProviderStatus `json:"status" table:"Status"` - BuiltIn bool `json:"built_in" table:"-"` - EnvproxyAccessURL string `json:"envproxy_access_url" table:"Access URL" validate:"required"` - DevurlHost string `json:"devurl_host" table:"Devurl Host"` - OrgWhitelist []string `json:"org_whitelist" table:"-"` - KubeProviderConfig `json:"config" table:"_"` -} - -// KubeProviderConfig defines Kubernetes-specific configuration options. -type KubeProviderConfig struct { - ClusterAddress string `json:"cluster_address" table:"Cluster Address"` - DefaultNamespace string `json:"default_namespace" table:"Namespace"` - StorageClass string `json:"storage_class" table:"Storage Class"` - ClusterDomainSuffix string `json:"cluster_domain_suffix" table:"Cluster Domain Suffix"` - SSHEnabled bool `json:"ssh_enabled" table:"SSH Enabled"` -} - -// WorkspaceProviderStatus represents the configuration state of a workspace provider. -type WorkspaceProviderStatus string - -// Workspace Provider statuses. -const ( - WorkspaceProviderPending WorkspaceProviderStatus = "pending" - WorkspaceProviderReady WorkspaceProviderStatus = "ready" -) - -// WorkspaceProviderType represents the type of workspace provider. -type WorkspaceProviderType string - -// Workspace Provider types. -const ( - WorkspaceProviderKubernetes WorkspaceProviderType = "kubernetes" -) - -// WorkspaceProviderByID fetches a workspace provider entity by its unique ID. -func (c *DefaultClient) WorkspaceProviderByID(ctx context.Context, id string) (*KubernetesProvider, error) { - var wp KubernetesProvider - err := c.requestBody(ctx, http.MethodGet, "/api/private/resource-pools/"+id, nil, &wp) - if err != nil { - return nil, err - } - return &wp, nil -} - -// WorkspaceProviders fetches all workspace providers known to the Coder control plane. -func (c *DefaultClient) WorkspaceProviders(ctx context.Context) (*WorkspaceProviders, error) { - var providers WorkspaceProviders - err := c.requestBody(ctx, http.MethodGet, "/api/private/resource-pools", nil, &providers) - if err != nil { - return nil, err - } - return &providers, nil -} - -// CreateWorkspaceProviderReq defines the request parameters for creating a new workspace provider entity. -type CreateWorkspaceProviderReq struct { - Name string `json:"name"` - Type WorkspaceProviderType `json:"type"` - Hostname string `json:"hostname"` - ClusterAddress string `json:"cluster_address"` -} - -// CreateWorkspaceProviderRes defines the response from creating a new workspace provider entity. -type CreateWorkspaceProviderRes struct { - ID string `json:"id" table:"ID"` - Name string `json:"name" table:"Name"` - Status WorkspaceProviderStatus `json:"status" table:"Status"` - EnvproxyToken string `json:"envproxy_token" table:"Envproxy Token"` -} - -// CreateWorkspaceProvider creates a new WorkspaceProvider entity. -func (c *DefaultClient) CreateWorkspaceProvider(ctx context.Context, req CreateWorkspaceProviderReq) (*CreateWorkspaceProviderRes, error) { - var res CreateWorkspaceProviderRes - err := c.requestBody(ctx, http.MethodPost, "/api/private/resource-pools", req, &res) - if err != nil { - return nil, err - } - return &res, nil -} - -// DeleteWorkspaceProviderByID deletes a workspace provider entity from the Coder control plane. -func (c *DefaultClient) DeleteWorkspaceProviderByID(ctx context.Context, id string) error { - return c.requestBody(ctx, http.MethodDelete, "/api/private/resource-pools/"+id, nil, nil) -} - -// CordoneWorkspaceProviderReq defines the request parameters for creating a new workspace provider entity. -type CordoneWorkspaceProviderReq struct { - Reason string `json:"reason"` -} - -// CordonWorkspaceProvider prevents the provider from having any more workspaces placed on it. -func (c *DefaultClient) CordonWorkspaceProvider(ctx context.Context, id, reason string) error { - req := CordoneWorkspaceProviderReq{Reason: reason} - err := c.requestBody(ctx, http.MethodPost, "/api/private/resource-pools/"+id+"/cordon", req, nil) - if err != nil { - return err - } - return nil -} - -// UnCordonWorkspaceProvider changes an existing cordoned providers status to 'Ready'; -// allowing it to continue creating new workspaces and provisioning resources for them. -func (c *DefaultClient) UnCordonWorkspaceProvider(ctx context.Context, id string) error { - err := c.requestBody(ctx, http.MethodPost, "/api/private/resource-pools/"+id+"/uncordon", nil, nil) - if err != nil { - return err - } - return nil -} - -// RenameWorkspaceProviderReq defines the request parameters for changing a workspace provider name. -type RenameWorkspaceProviderReq struct { - Name string `json:"name"` -} - -// RenameWorkspaceProvider changes an existing cordoned providers name field. -func (c *DefaultClient) RenameWorkspaceProvider(ctx context.Context, id string, name string) error { - req := RenameWorkspaceProviderReq{Name: name} - err := c.requestBody(ctx, http.MethodPatch, "/api/private/resource-pools/"+id, req, nil) - if err != nil { - return err - } - return nil -} diff --git a/coder-sdk/ws.go b/coder-sdk/ws.go deleted file mode 100644 index 89cb28e8..00000000 --- a/coder-sdk/ws.go +++ /dev/null @@ -1,35 +0,0 @@ -package coder - -import ( - "context" - "net/http" - - "nhooyr.io/websocket" -) - -// dialWebsocket establish the websocket connection while setting the authentication header. -func (c *DefaultClient) dialWebsocket(ctx context.Context, path string, options ...requestOption) (*websocket.Conn, error) { - // Make a copy of the url so we can update the scheme to ws(s) without mutating the state. - url := *c.baseURL - var config requestOptions - for _, o := range options { - o(&config) - } - if config.BaseURLOverride != nil { - url = *config.BaseURLOverride - } - url.Path = path - - headers := http.Header{} - headers.Set("Session-Token", c.token) - - conn, resp, err := websocket.Dial(ctx, url.String(), &websocket.DialOptions{HTTPHeader: headers}) - if err != nil { - if resp != nil { - return nil, NewHTTPError(resp) - } - return nil, err - } - - return conn, nil -} diff --git a/docs/coder.md b/docs/coder.md deleted file mode 100644 index 17e7fa7f..00000000 --- a/docs/coder.md +++ /dev/null @@ -1,26 +0,0 @@ -## coder - -coder provides a CLI for working with an existing Coder installation - -### Options - -``` - -h, --help help for coder - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder completion](coder_completion.md) - Generate completion script -* [coder config-ssh](coder_config-ssh.md) - Configure SSH to access Coder workspaces -* [coder images](coder_images.md) - Manage Coder images -* [coder login](coder_login.md) - Authenticate this client for future operations -* [coder logout](coder_logout.md) - Remove local authentication credentials if any exist -* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments -* [coder ssh](coder_ssh.md) - Enter a shell of execute a command over SSH into a Coder workspace -* [coder sync](coder_sync.md) - Establish a one way directory sync to a Coder workspace -* [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user -* [coder urls](coder_urls.md) - Interact with workspace DevURLs -* [coder users](coder_users.md) - Interact with Coder user accounts -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_completion.md b/docs/coder_completion.md deleted file mode 100644 index 143dcac7..00000000 --- a/docs/coder_completion.md +++ /dev/null @@ -1,70 +0,0 @@ -## coder completion - -Generate completion script - -### Synopsis - -To load completions: - -Bash: - -$ source <(coder completion bash) - -To load completions for each session, execute once: -Linux: - $ coder completion bash > /etc/bash_completion.d/coder -MacOS: - $ coder completion bash > /usr/local/etc/bash_completion.d/coder - -Zsh: - -If shell completion is not already enabled in your workspace you will need -to enable it. You can execute the following once: - -$ echo "autoload -U compinit; compinit" >> ~/.zshrc - -To load completions for each session, execute once: -$ coder completion zsh > "${fpath[1]}/_coder" - -You will need to start a new shell for this setup to take effect. - -Fish: - -$ coder completion fish | source - -To load completions for each session, execute once: -$ coder completion fish > ~/.config/fish/completions/coder.fish - - -``` -coder completion [bash|zsh|fish|powershell] -``` - -### Examples - -``` -coder completion fish > ~/.config/fish/completions/coder.fish -coder completion zsh > "${fpath[1]}/_coder" - -Linux: - $ coder completion bash > /etc/bash_completion.d/coder -MacOS: - $ coder completion bash > /usr/local/etc/bash_completion.d/coder -``` - -### Options - -``` - -h, --help help for completion -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_config-ssh.md b/docs/coder_config-ssh.md deleted file mode 100644 index 4114239f..00000000 --- a/docs/coder_config-ssh.md +++ /dev/null @@ -1,31 +0,0 @@ -## coder config-ssh - -Configure SSH to access Coder workspaces - -### Synopsis - -Inject the proper OpenSSH configuration into your local SSH config file. - -``` -coder config-ssh [flags] -``` - -### Options - -``` - --filepath string override the default path of your ssh config file (default "~/.ssh/config") - -h, --help help for config-ssh - -o, --option strings additional options injected in the ssh config (ex. disable caching with "-o ControlPath=none") - --remove remove the auto-generated Coder ssh config -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_images.md b/docs/coder_images.md deleted file mode 100644 index 78c875ed..00000000 --- a/docs/coder_images.md +++ /dev/null @@ -1,26 +0,0 @@ -## coder images - -Manage Coder images - -### Synopsis - -Manage existing images and/or import new ones. - -### Options - -``` - -h, --help help for images - --user string Specifies the user by email (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder images ls](coder_images_ls.md) - list all images available to the active user - diff --git a/docs/coder_images_ls.md b/docs/coder_images_ls.md deleted file mode 100644 index bfb646d5..00000000 --- a/docs/coder_images_ls.md +++ /dev/null @@ -1,31 +0,0 @@ -## coder images ls - -list all images available to the active user - -### Synopsis - -List all Coder images available to the active user. - -``` -coder images ls [flags] -``` - -### Options - -``` - -h, --help help for ls - --org string organization name - --output string human | json (default "human") -``` - -### Options inherited from parent commands - -``` - --user string Specifies the user by email (default "me") - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder images](coder_images.md) - Manage Coder images - diff --git a/docs/coder_login.md b/docs/coder_login.md deleted file mode 100644 index ff20bf7e..00000000 --- a/docs/coder_login.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder login - -Authenticate this client for future operations - -``` -coder login [Coder URL eg. https://my.coder.domain/] [flags] -``` - -### Options - -``` - -h, --help help for login -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_logout.md b/docs/coder_logout.md deleted file mode 100644 index cfb1f4c4..00000000 --- a/docs/coder_logout.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder logout - -Remove local authentication credentials if any exist - -``` -coder logout [flags] -``` - -### Options - -``` - -h, --help help for logout -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_satellites.md b/docs/coder_satellites.md deleted file mode 100644 index 2eaac5b9..00000000 --- a/docs/coder_satellites.md +++ /dev/null @@ -1,27 +0,0 @@ -## coder satellites - -Interact with Coder satellite deployments - -### Synopsis - -Perform operations on the Coder satellites for the platform. - -### Options - -``` - -h, --help help for satellites -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder satellites create](coder_satellites_create.md) - create a new satellite. -* [coder satellites ls](coder_satellites_ls.md) - list satellites. -* [coder satellites rm](coder_satellites_rm.md) - remove a satellite. - diff --git a/docs/coder_satellites_create.md b/docs/coder_satellites_create.md deleted file mode 100644 index 9ab18362..00000000 --- a/docs/coder_satellites_create.md +++ /dev/null @@ -1,36 +0,0 @@ -## coder satellites create - -create a new satellite. - -### Synopsis - -Create a new Coder satellite. - -``` -coder satellites create [name] [satellite_access_url] [flags] -``` - -### Examples - -``` -# create a new satellite - -coder satellites create eu-west https://eu-west.coder.com -``` - -### Options - -``` - -h, --help help for create -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments - diff --git a/docs/coder_satellites_ls.md b/docs/coder_satellites_ls.md deleted file mode 100644 index d2153685..00000000 --- a/docs/coder_satellites_ls.md +++ /dev/null @@ -1,35 +0,0 @@ -## coder satellites ls - -list satellites. - -### Synopsis - -List all Coder workspace satellites. - -``` -coder satellites ls [flags] -``` - -### Examples - -``` -# list satellites -coder satellites ls -``` - -### Options - -``` - -h, --help help for ls -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments - diff --git a/docs/coder_satellites_rm.md b/docs/coder_satellites_rm.md deleted file mode 100644 index 44669f6f..00000000 --- a/docs/coder_satellites_rm.md +++ /dev/null @@ -1,35 +0,0 @@ -## coder satellites rm - -remove a satellite. - -### Synopsis - -Remove an existing Coder satellite by name. - -``` -coder satellites rm [satellite_name] [flags] -``` - -### Examples - -``` -# remove an existing satellite by name -coder satellites rm my-satellite -``` - -### Options - -``` - -h, --help help for rm -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments - diff --git a/docs/coder_ssh.md b/docs/coder_ssh.md deleted file mode 100644 index d57ac50e..00000000 --- a/docs/coder_ssh.md +++ /dev/null @@ -1,31 +0,0 @@ -## coder ssh - -Enter a shell of execute a command over SSH into a Coder workspace - -``` -coder ssh [workspace_name] [] -``` - -### Examples - -``` -coder ssh my-dev -coder ssh my-dev pwd -``` - -### Options - -``` - -h, --help help for ssh -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_sync.md b/docs/coder_sync.md deleted file mode 100644 index 516f3160..00000000 --- a/docs/coder_sync.md +++ /dev/null @@ -1,25 +0,0 @@ -## coder sync - -Establish a one way directory sync to a Coder workspace - -``` -coder sync [local directory] [:] [flags] -``` - -### Options - -``` - -h, --help help for sync - --init do initial transfer and exit -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation - diff --git a/docs/coder_tokens.md b/docs/coder_tokens.md deleted file mode 100644 index 7b884e5b..00000000 --- a/docs/coder_tokens.md +++ /dev/null @@ -1,29 +0,0 @@ -## coder tokens - -manage Coder API tokens for the active user - -### Synopsis - -Create and manage API Tokens for authenticating the CLI. -Statically authenticate using the token value with the `CODER_TOKEN` and `CODER_URL` workspace variables. - -### Options - -``` - -h, --help help for tokens -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder tokens create](coder_tokens_create.md) - create generates a new API token and prints it to stdout -* [coder tokens ls](coder_tokens_ls.md) - show the user's active API tokens -* [coder tokens regen](coder_tokens_regen.md) - regenerate an API token by its unique ID and print the new token to stdout -* [coder tokens rm](coder_tokens_rm.md) - remove an API token by its unique ID - diff --git a/docs/coder_tokens_create.md b/docs/coder_tokens_create.md deleted file mode 100644 index a7a89f54..00000000 --- a/docs/coder_tokens_create.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder tokens create - -create generates a new API token and prints it to stdout - -``` -coder tokens create [token_name] [flags] -``` - -### Options - -``` - -h, --help help for create -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user - diff --git a/docs/coder_tokens_ls.md b/docs/coder_tokens_ls.md deleted file mode 100644 index 6790700d..00000000 --- a/docs/coder_tokens_ls.md +++ /dev/null @@ -1,25 +0,0 @@ -## coder tokens ls - -show the user's active API tokens - -``` -coder tokens ls [flags] -``` - -### Options - -``` - -h, --help help for ls - -o, --output string human | json (default "human") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user - diff --git a/docs/coder_tokens_regen.md b/docs/coder_tokens_regen.md deleted file mode 100644 index 26832102..00000000 --- a/docs/coder_tokens_regen.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder tokens regen - -regenerate an API token by its unique ID and print the new token to stdout - -``` -coder tokens regen [token_id] [flags] -``` - -### Options - -``` - -h, --help help for regen -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user - diff --git a/docs/coder_tokens_rm.md b/docs/coder_tokens_rm.md deleted file mode 100644 index ca95ee0e..00000000 --- a/docs/coder_tokens_rm.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder tokens rm - -remove an API token by its unique ID - -``` -coder tokens rm [token_id] [flags] -``` - -### Options - -``` - -h, --help help for rm -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user - diff --git a/docs/coder_urls.md b/docs/coder_urls.md deleted file mode 100644 index 2bb3e393..00000000 --- a/docs/coder_urls.md +++ /dev/null @@ -1,23 +0,0 @@ -## coder urls - -Interact with workspace DevURLs - -### Options - -``` - -h, --help help for urls -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder urls create](coder_urls_create.md) - Create a new dev URL for a workspace -* [coder urls ls](coder_urls_ls.md) - List all DevURLs for a workspace -* [coder urls rm](coder_urls_rm.md) - Remove a dev url - diff --git a/docs/coder_urls_create.md b/docs/coder_urls_create.md deleted file mode 100644 index eb3ed6fa..00000000 --- a/docs/coder_urls_create.md +++ /dev/null @@ -1,33 +0,0 @@ -## coder urls create - -Create a new dev URL for a workspace - -``` -coder urls create [workspace_name] [port] [flags] -``` - -### Examples - -``` -coder urls create my-workspace 8080 --name my-dev-url -``` - -### Options - -``` - --access string Set DevURL access to [private | org | authed | public] (default "private") - -h, --help help for create - --name string DevURL name - --scheme string Server scheme (http|https) (default "http") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder urls](coder_urls.md) - Interact with workspace DevURLs - diff --git a/docs/coder_urls_ls.md b/docs/coder_urls_ls.md deleted file mode 100644 index 79048d9e..00000000 --- a/docs/coder_urls_ls.md +++ /dev/null @@ -1,25 +0,0 @@ -## coder urls ls - -List all DevURLs for a workspace - -``` -coder urls ls [workspace_name] [flags] -``` - -### Options - -``` - -h, --help help for ls - -o, --output string human|json (default "human") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder urls](coder_urls.md) - Interact with workspace DevURLs - diff --git a/docs/coder_urls_rm.md b/docs/coder_urls_rm.md deleted file mode 100644 index 5a25a3bf..00000000 --- a/docs/coder_urls_rm.md +++ /dev/null @@ -1,24 +0,0 @@ -## coder urls rm - -Remove a dev url - -``` -coder urls rm [workspace_name] [port] [flags] -``` - -### Options - -``` - -h, --help help for rm -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder urls](coder_urls.md) - Interact with workspace DevURLs - diff --git a/docs/coder_users.md b/docs/coder_users.md deleted file mode 100644 index 2bfadc7c..00000000 --- a/docs/coder_users.md +++ /dev/null @@ -1,21 +0,0 @@ -## coder users - -Interact with Coder user accounts - -### Options - -``` - -h, --help help for users -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder users ls](coder_users_ls.md) - list all user accounts - diff --git a/docs/coder_users_ls.md b/docs/coder_users_ls.md deleted file mode 100644 index ea7b4d4c..00000000 --- a/docs/coder_users_ls.md +++ /dev/null @@ -1,32 +0,0 @@ -## coder users ls - -list all user accounts - -``` -coder users ls [flags] -``` - -### Examples - -``` -coder users ls -o json -coder users ls -o json | jq .[] | jq -r .email -``` - -### Options - -``` - -h, --help help for ls - -o, --output string human | json (default "human") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder users](coder_users.md) - Interact with Coder user accounts - diff --git a/docs/coder_workspaces.md b/docs/coder_workspaces.md deleted file mode 100644 index 936db713..00000000 --- a/docs/coder_workspaces.md +++ /dev/null @@ -1,35 +0,0 @@ -## coder workspaces - -Interact with Coder workspaces - -### Synopsis - -Perform operations on the Coder workspaces owned by the active user. - -### Options - -``` - -h, --help help for workspaces -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation -* [coder workspaces create](coder_workspaces_create.md) - create a new workspace. -* [coder workspaces create-from-config](coder_workspaces_create-from-config.md) - create a new workspace from a template -* [coder workspaces edit](coder_workspaces_edit.md) - edit an existing workspace and initiate a rebuild. -* [coder workspaces edit-from-config](coder_workspaces_edit-from-config.md) - change the template a workspace is tracking -* [coder workspaces ls](coder_workspaces_ls.md) - list all workspaces owned by the active user -* [coder workspaces ping](coder_workspaces_ping.md) - ping Coder workspaces by name -* [coder workspaces policy-template](coder_workspaces_policy-template.md) - Set workspace policy template -* [coder workspaces rebuild](coder_workspaces_rebuild.md) - rebuild a Coder workspace -* [coder workspaces rm](coder_workspaces_rm.md) - remove Coder workspaces by name -* [coder workspaces stop](coder_workspaces_stop.md) - stop Coder workspaces by name -* [coder workspaces watch-build](coder_workspaces_watch-build.md) - trail the build log of a Coder workspace - diff --git a/docs/coder_workspaces_create-from-config.md b/docs/coder_workspaces_create-from-config.md deleted file mode 100644 index 221175b7..00000000 --- a/docs/coder_workspaces_create-from-config.md +++ /dev/null @@ -1,43 +0,0 @@ -## coder workspaces create-from-config - -create a new workspace from a template - -### Synopsis - -Create a new Coder workspace using a Workspaces As Code template. - -``` -coder workspaces create-from-config [flags] -``` - -### Examples - -``` -# create a new workspace from git repository -coder envs create-from-config --name="dev-env" --repo-url https://github.com/cdr/m --ref my-branch -coder envs create-from-config --name="dev-env" -f coder.yaml -``` - -### Options - -``` - -f, --filepath string path to local template file. - --follow follow buildlog after initiating rebuild - -h, --help help for create-from-config - --name string name of the workspace to be created - -o, --org string name of the organization the workspace should be created under. - --provider string name of Workspace Provider with which to create the workspace - --ref string git reference to pull template from. May be a branch, tag, or commit hash. (default "master") - -r, --repo-url string URL of the git repository to pull the config from. Config file must live in '.coder/coder.yaml'. -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_create.md b/docs/coder_workspaces_create.md deleted file mode 100644 index 56732f89..00000000 --- a/docs/coder_workspaces_create.md +++ /dev/null @@ -1,47 +0,0 @@ -## coder workspaces create - -create a new workspace. - -### Synopsis - -Create a new Coder workspace. - -``` -coder workspaces create [workspace_name] [flags] -``` - -### Examples - -``` -# create a new workspace using default resource amounts -coder workspaces create my-new-workspace --image ubuntu -coder workspaces create my-new-powerful-workspace --cpu 12 --disk 100 --memory 16 --image ubuntu -``` - -### Options - -``` - --container-based-vm deploy the workspace as a Container-based VM - -c, --cpu float32 number of cpu cores the workspace should be provisioned with. - -d, --disk int GB of disk storage a workspace should be provisioned with. - --enable-autostart automatically start this workspace at your preferred time. - --follow follow buildlog after initiating rebuild - -g, --gpus int number GPUs a workspace should be provisioned with. - -h, --help help for create - -i, --image string name of the image to base the workspace off of. - -m, --memory float32 GB of RAM a workspace should be provisioned with. - -o, --org string name of the organization the workspace should be created under. - --provider string name of Workspace Provider with which to create the workspace - -t, --tag string tag of the image the workspace will be based off of. (default "latest") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_edit-from-config.md b/docs/coder_workspaces_edit-from-config.md deleted file mode 100644 index 085e1470..00000000 --- a/docs/coder_workspaces_edit-from-config.md +++ /dev/null @@ -1,38 +0,0 @@ -## coder workspaces edit-from-config - -change the template a workspace is tracking - -### Synopsis - -Edit an existing Coder workspace using a Workspaces As Code template. - -``` -coder workspaces edit-from-config [flags] -``` - -### Examples - -``` -# edit a new workspace from git repository -coder envs edit-from-config dev-env --repo-url https://github.com/cdr/m --ref my-branch -coder envs edit-from-config dev-env -f coder.yaml -``` - -### Options - -``` - -f, --filepath string path to local template file. - --follow follow buildlog after initiating rebuild - -h, --help help for edit-from-config -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_edit.md b/docs/coder_workspaces_edit.md deleted file mode 100644 index 2d214cb4..00000000 --- a/docs/coder_workspaces_edit.md +++ /dev/null @@ -1,46 +0,0 @@ -## coder workspaces edit - -edit an existing workspace and initiate a rebuild. - -### Synopsis - -Edit an existing workspace and initate a rebuild. - -``` -coder workspaces edit [flags] -``` - -### Examples - -``` -coder workspaces edit back-end-workspace --cpu 4 - -coder workspaces edit back-end-workspace --disk 20 -``` - -### Options - -``` - -c, --cpu float32 The number of cpu cores the workspace should be provisioned with. - -d, --disk int The amount of disk storage a workspace should be provisioned with. - --follow follow buildlog after initiating rebuild - --force force rebuild without showing a confirmation prompt - -g, --gpu int The amount of disk storage to provision the workspace with. - -h, --help help for edit - -i, --image string name of the image you want the workspace to be based off of. - -m, --memory float32 The amount of RAM a workspace should be provisioned with. - -o, --org string name of the organization the workspace should be created under. - -t, --tag string image tag of the image you want to base the workspace off of. (default "latest") - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_ls.md b/docs/coder_workspaces_ls.md deleted file mode 100644 index cd940e2b..00000000 --- a/docs/coder_workspaces_ls.md +++ /dev/null @@ -1,31 +0,0 @@ -## coder workspaces ls - -list all workspaces owned by the active user - -### Synopsis - -List all Coder workspaces owned by the active user. - -``` -coder workspaces ls [flags] -``` - -### Options - -``` - -h, --help help for ls - -o, --output string human | json (default "human") - -p, --provider string Filter workspaces by a particular workspace provider name. - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_ping.md b/docs/coder_workspaces_ping.md deleted file mode 100644 index cf55fff1..00000000 --- a/docs/coder_workspaces_ping.md +++ /dev/null @@ -1,36 +0,0 @@ -## coder workspaces ping - -ping Coder workspaces by name - -### Synopsis - -ping Coder workspaces by name - -``` -coder workspaces ping [flags] -``` - -### Examples - -``` -coder workspaces ping front-end-workspace -``` - -### Options - -``` - -c, --count int stop after replies - -h, --help help for ping - -s, --scheme strings customize schemes to filter ice servers (default [stun,stuns,turn,turns]) -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_policy-template.md b/docs/coder_workspaces_policy-template.md deleted file mode 100644 index 36bf34fc..00000000 --- a/docs/coder_workspaces_policy-template.md +++ /dev/null @@ -1,32 +0,0 @@ -## coder workspaces policy-template - -Set workspace policy template - -### Synopsis - -Set workspace policy template or restore to default configuration. This feature is for site admins only. - -``` -coder workspaces policy-template [flags] -``` - -### Options - -``` - --default Restore policy template to default configuration - --dry-run skip setting policy template, but view errors/warnings about how this policy template would impact existing workspaces - -f, --filepath string full path to local policy template file. - -h, --help help for policy-template - --scope string scope of impact for the policy template. Supported values: site (default "site") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_rebuild.md b/docs/coder_workspaces_rebuild.md deleted file mode 100644 index 8e928852..00000000 --- a/docs/coder_workspaces_rebuild.md +++ /dev/null @@ -1,34 +0,0 @@ -## coder workspaces rebuild - -rebuild a Coder workspace - -``` -coder workspaces rebuild [workspace_name] [flags] -``` - -### Examples - -``` -coder workspaces rebuild front-end-workspace --follow -coder workspaces rebuild backend-workspace --force -``` - -### Options - -``` - --follow follow build log after initiating rebuild - --force force rebuild without showing a confirmation prompt - -h, --help help for rebuild - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_rm.md b/docs/coder_workspaces_rm.md deleted file mode 100644 index b19ece2d..00000000 --- a/docs/coder_workspaces_rm.md +++ /dev/null @@ -1,26 +0,0 @@ -## coder workspaces rm - -remove Coder workspaces by name - -``` -coder workspaces rm [...workspace_names] [flags] -``` - -### Options - -``` - -f, --force force remove the specified workspaces without prompting first - -h, --help help for rm - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_stop.md b/docs/coder_workspaces_stop.md deleted file mode 100644 index 0e00ecef..00000000 --- a/docs/coder_workspaces_stop.md +++ /dev/null @@ -1,44 +0,0 @@ -## coder workspaces stop - -stop Coder workspaces by name - -### Synopsis - -Stop Coder workspaces by name - -``` -coder workspaces stop [...workspace_names] [flags] -``` - -### Examples - -``` -coder workspaces stop front-end-workspace -coder workspaces stop front-end-workspace backend-workspace - -# stop all of your workspaces -coder workspaces ls -o json | jq -c '.[].name' | xargs coder workspaces stop - -# stop all workspaces for a given user -coder workspaces --user charlie@coder.com ls -o json \ - | jq -c '.[].name' \ - | xargs coder workspaces --user charlie@coder.com stop -``` - -### Options - -``` - -h, --help help for stop - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/docs/coder_workspaces_watch-build.md b/docs/coder_workspaces_watch-build.md deleted file mode 100644 index 8af34e82..00000000 --- a/docs/coder_workspaces_watch-build.md +++ /dev/null @@ -1,31 +0,0 @@ -## coder workspaces watch-build - -trail the build log of a Coder workspace - -``` -coder workspaces watch-build [workspace_name] [flags] -``` - -### Examples - -``` -coder workspaces watch-build front-end-workspace -``` - -### Options - -``` - -h, --help help for watch-build - --user string Specify the user whose resources to target (default "me") -``` - -### Options inherited from parent commands - -``` - -v, --verbose show verbose output -``` - -### SEE ALSO - -* [coder workspaces](coder_workspaces.md) - Interact with Coder workspaces - diff --git a/go.mod b/go.mod deleted file mode 100644 index 09be0cc6..00000000 --- a/go.mod +++ /dev/null @@ -1,36 +0,0 @@ -module cdr.dev/coder-cli - -go 1.14 - -require ( - cdr.dev/slog v1.4.1 - cdr.dev/wsep v0.0.0-20200728013649-82316a09813f - github.com/briandowns/spinner v1.16.0 - github.com/cli/safeexec v1.0.0 - github.com/fatih/color v1.12.0 - github.com/google/go-cmp v0.5.6 - github.com/gorilla/websocket v1.4.2 - github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 - github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f - github.com/klauspost/compress v1.10.8 // indirect - github.com/manifoldco/promptui v0.8.0 - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/pion/datachannel v1.4.21 - github.com/pion/dtls/v2 v2.0.9 - github.com/pion/ice/v2 v2.1.10 - github.com/pion/logging v0.2.2 - github.com/pion/turn/v2 v2.0.5 - github.com/pion/webrtc/v3 v3.0.32 - github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 - github.com/rjeczalik/notify v0.9.2 - github.com/spf13/cobra v1.2.1 - github.com/stretchr/testify v1.7.0 - golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 - golang.org/x/net v0.0.0-20210614182718-04defd469f4e - golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 - golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 - golang.org/x/time v0.0.0-20191024005414-555d28b269f0 - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 - nhooyr.io/websocket v1.8.7 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index de738e5e..00000000 --- a/go.sum +++ /dev/null @@ -1,810 +0,0 @@ -cdr.dev/slog v1.3.0/go.mod h1:C5OL99WyuOK8YHZdYY57dAPN1jK2WJlCdq2VP6xeQns= -cdr.dev/slog v1.4.1 h1:Q8+X63m8/WB4geelMTDO8t4CTwVh1f7+5Cxi7kS/SZg= -cdr.dev/slog v1.4.1/go.mod h1:O76C6gZJxa5HK1SXMrjd48V2kJxYZKFRTcFfn/V9OhA= -cdr.dev/wsep v0.0.0-20200728013649-82316a09813f h1:WnTUINBwXE11xjp5nTVt+H2qB2/KEymos1jKMcppG9U= -cdr.dev/wsep v0.0.0-20200728013649-82316a09813f/go.mod h1:2VKClUml3gfmLez0gBxTJIjSKszpQotc2ZqPdApfK/Y= -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.49.0/go.mod h1:hGvAdzcWNbyuxS3nWhD7H2cIJxjRRTRLQVB0bdputVY= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go v0.83.0 h1:bAMqZidYkmIsUqe6PtkEPT7Q+vfizScn+jfNA6jwK9c= -cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= -github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= -github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= -github.com/alecthomas/chroma v0.9.1 h1:cBmvQqRImzR5aWqdMxYZByND4S7BCS/g0svZb28h0Dc= -github.com/alecthomas/chroma v0.9.1/go.mod h1:eMuEnpA18XbG/WhOWtCzJHS7WqEtDAI+HxdwoW0nVSk= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/briandowns/spinner v1.16.0 h1:DFmp6hEaIx2QXXuqSJmtfSBSAjRmpGiKG6ip2Wm/yOs= -github.com/briandowns/spinner v1.16.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10 h1:Swpa1K6QvQznwJRcfTfQJmTE72DqScAa40E+fbHEXEE= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e h1:fY5BOSpyZCqRo5OhCuC+XN+r/bBCmeuuJtjz+bCNIf8= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1 h1:q763qf9huN11kDQavWsoZXJNW3xEE4JJyHa5Q25/sd8= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= -github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= -github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.12.0 h1:mRhaKNwANqRgUBGKmnI5ZxEk7QXmjQeCcuYFMX2bfcc= -github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= -github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= -github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= -github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= -github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= -github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= -github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0= -github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= -github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= -github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= -github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= -github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= -github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864 h1:Y4V+SFe7d3iH+9pJCoeWIOS5/xBJIFsltS7E+KJSsJY= -github.com/hashicorp/yamux v0.0.0-20210316155119-a95892c5f864/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a h1:FaWFmfWdAUKbSCtOU2QjDaorUexogfaMgbipgYATUMU= -github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a/go.mod h1:UJSiEoRfvx3hP73CvoARgeLjaIOjybY9vj8PUPPFGeU= -github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= -github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.10.8 h1:eLeJ3dr/Y9+XRfJT4l+8ZjmtB5RPJhucH2HeCV5+IZY= -github.com/klauspost/compress v1.10.8/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= -github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a h1:weJVJJRzAJBFRlAiJQROKQs8oC9vOxvm4rZmBBk0ONw= -github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/manifoldco/promptui v0.8.0 h1:R95mMF+McvXZQ7j1g8ucVZE1gLP3Sv6j9vlF9kyRqQo= -github.com/manifoldco/promptui v0.8.0/go.mod h1:n4zTdgP0vr0S3w7/O/g98U+e0gwLScEXGwov2nIKuGQ= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9AWI= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.16.1/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.11.0/go.mod h1:azGKhqFUon9Vuj0YmTfLSmx0FUwqXYSTl5re8lQLTUg= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pion/datachannel v1.4.21 h1:3ZvhNyfmxsAqltQrApLPQMhSFNA+aT87RqyCq4OXmf0= -github.com/pion/datachannel v1.4.21/go.mod h1:oiNyP4gHx2DIwRzX/MFyH0Rz/Gz05OgBlayAI2hAWjg= -github.com/pion/dtls/v2 v2.0.9 h1:7Ow+V++YSZQMYzggI0P9vLJz/hUFcffsfGMfT/Qy+u8= -github.com/pion/dtls/v2 v2.0.9/go.mod h1:O0Wr7si/Zj5/EBFlDzDd6UtVxx25CE1r7XM7BQKYQho= -github.com/pion/ice/v2 v2.1.10 h1:Jt/BfUsaP+Dr6E5rbsy+w7w1JtHyFN0w2DkgfWq7Fko= -github.com/pion/ice/v2 v2.1.10/go.mod h1:kV4EODVD5ux2z8XncbLHIOtcXKtYXVgLVCeVqnpoeP0= -github.com/pion/interceptor v0.0.13 h1:fnV+b0p/KEzwwr/9z2nsSqA9IQRMsM4nF5HjrNSWwBo= -github.com/pion/interceptor v0.0.13/go.mod h1:svsW2QoLHLoGLUr4pDoSopGBEWk8FZwlfxId/OKRKzo= -github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= -github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= -github.com/pion/mdns v0.0.5 h1:Q2oj/JB3NqfzY9xGZ1fPzZzK7sDSD8rZPOvcIQ10BCw= -github.com/pion/mdns v0.0.5/go.mod h1:UgssrvdD3mxpi8tMxAXbsppL3vJ4Jipw1mTCW+al01g= -github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= -github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.6 h1:1zvwBbyd0TeEuuWftrd/4d++m+/kZSeiguxU61LFWpo= -github.com/pion/rtcp v1.2.6/go.mod h1:52rMNPWFsjr39z9B9MhnkqhPLoeHTv1aN63o/42bWE0= -github.com/pion/rtp v1.6.2/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/rtp v1.6.5 h1:o2cZf8OascA5HF/b0PAbTxRKvOWxTQxWYt7SlToxFGI= -github.com/pion/rtp v1.6.5/go.mod h1:bDb5n+BFZxXx0Ea7E5qe+klMuqiBrP+w8XSjiWtCUko= -github.com/pion/sctp v1.7.10/go.mod h1:EhpTUQu1/lcK3xI+eriS6/96fWetHGCvBi9MSsnaBN0= -github.com/pion/sctp v1.7.12 h1:GsatLufywVruXbZZT1CKg+Jr8ZTkwiPnmUC/oO9+uuY= -github.com/pion/sctp v1.7.12/go.mod h1:xFe9cLMZ5Vj6eOzpyiKjT9SwGM4KpK/8Jbw5//jc+0s= -github.com/pion/sdp/v3 v3.0.4 h1:2Kf+dgrzJflNCSw3TV5v2VLeI0s/qkzy2r5jlR0wzf8= -github.com/pion/sdp/v3 v3.0.4/go.mod h1:bNiSknmJE0HYBprTHXKPQ3+JjacTv5uap92ueJZKsRk= -github.com/pion/srtp/v2 v2.0.2 h1:664iGzVmaY7KYS5M0gleY0DscRo9ReDfTxQrq4UgGoU= -github.com/pion/srtp/v2 v2.0.2/go.mod h1:VEyLv4CuxrwGY8cxM+Ng3bmVy8ckz/1t6A0q/msKOw0= -github.com/pion/stun v0.3.5 h1:uLUCBCkQby4S1cf6CGuR9QrVOKcvUwFeemaC865QHDg= -github.com/pion/stun v0.3.5/go.mod h1:gDMim+47EeEtfWogA37n6qXZS88L5V6LqFcf+DZA2UA= -github.com/pion/transport v0.10.1/go.mod h1:PBis1stIILMiis0PewDw91WJeLJkyIMcEk+DwKOzf4A= -github.com/pion/transport v0.12.2/go.mod h1:N3+vZQD9HlDP5GWkZ85LohxNsDcNgofQmyL6ojX5d8Q= -github.com/pion/transport v0.12.3 h1:vdBfvfU/0Wq8kd2yhUMSDB/x+O4Z9MYVl2fJ5BT4JZw= -github.com/pion/transport v0.12.3/go.mod h1:OViWW9SP2peE/HbwBvARicmAVnesphkNkCVZIWJ6q9A= -github.com/pion/turn/v2 v2.0.5 h1:iwMHqDfPEDEOFzwWKT56eFmh6DYC6o/+xnLAEzgISbA= -github.com/pion/turn/v2 v2.0.5/go.mod h1:APg43CFyt/14Uy7heYUOGWdkem/Wu4PhCO/bjyrTqMw= -github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o= -github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= -github.com/pion/webrtc/v3 v3.0.32 h1:5J+zNep9am8Swh6kEMp+LaGXNvn6qQWpGkLBnVW44L4= -github.com/pion/webrtc/v3 v3.0.32/go.mod h1:wX3V5dQQUGCifhT1mYftC2kCrDQX6ZJ3B7Yad0R9JK0= -github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4 h1:49lOXmGaUpV9Fz3gd7TFZY106KVlPVa5jcYD1gaQf98= -github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8= -github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= -github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= -github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= -github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -go.coder.com/cli v0.4.0/go.mod h1:hRTOURCR3LJF1FRW9arecgrzX+AHG7mfYMwThPIgq+w= -go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512 h1:DjCS6dRQh+1PlfiBmnabxfdrzenb0tAwJqFxDEH/s9g= -go.coder.com/flog v0.0.0-20190906214207-47dd47ea0512/go.mod h1:83JsYgXYv0EOaXjIMnaZ1Fl6ddNB3fJnDZ/8845mUJ8= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201201195509-5d6afe98e0b7/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c h1:wtujag7C+4D6KMoulW9YauvK2lgdvCMS260jsqqBXr0= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -nhooyr.io/websocket v1.8.6/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= -nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= -nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/internal/activity/pusher.go b/internal/activity/pusher.go deleted file mode 100644 index f432c72e..00000000 --- a/internal/activity/pusher.go +++ /dev/null @@ -1,47 +0,0 @@ -package activity - -import ( - "context" - "fmt" - "time" - - "golang.org/x/time/rate" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" -) - -const pushInterval = time.Minute - -// Pusher pushes activity metrics no more than once per pushInterval. Pushes -// within the same interval are a no-op. -type Pusher struct { - workspaceID string - source string - - client coder.Client - rate *rate.Limiter // Use a rate limiter to control the sampling rate. -} - -// NewPusher instantiates a new instance of Pusher. -func NewPusher(c coder.Client, workspaceID, source string) *Pusher { - return &Pusher{ - workspaceID: workspaceID, - source: source, - client: c, - // Sample only 1 per interval to avoid spamming the api. - rate: rate.NewLimiter(rate.Every(pushInterval), 1), - } -} - -// Push pushes activity, abiding by a rate limit. -func (p *Pusher) Push(ctx context.Context) { - // If we already sampled data within the allowable range, do nothing. - if !p.rate.Allow() { - return - } - - if err := p.client.PushActivity(ctx, p.source, p.workspaceID); err != nil { - clog.Log(clog.Error(fmt.Sprintf("push activity: %s", err))) - } -} diff --git a/internal/activity/writer.go b/internal/activity/writer.go deleted file mode 100644 index 02d9d1b8..00000000 --- a/internal/activity/writer.go +++ /dev/null @@ -1,24 +0,0 @@ -// Package activity defines the logic for tracking usage activity metrics. -package activity - -import ( - "context" - "io" -) - -// writer wraps a standard io.Writer with the activity pusher. -type writer struct { - p *Pusher - wr io.Writer -} - -// Write writes to the underlying writer and tracks activity. -func (w *writer) Write(buf []byte) (int, error) { - w.p.Push(context.Background()) - return w.wr.Write(buf) -} - -// Writer wraps the given writer such that all writes trigger an activity push. -func (p *Pusher) Writer(wr io.Writer) io.Writer { - return &writer{p: p, wr: wr} -} diff --git a/internal/cmd/agent.go b/internal/cmd/agent.go deleted file mode 100644 index 14bd11ab..00000000 --- a/internal/cmd/agent.go +++ /dev/null @@ -1,107 +0,0 @@ -package cmd - -import ( - "net/url" - "os" - "os/signal" - "syscall" - - // We use slog here since agent runs in the background and we can benefit - // from structured logging. - "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/wsnet" -) - -func agentCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "agent", - Short: "Run the workspace agent", - Long: "Connect to Coder and start running a p2p agent", - Hidden: true, - } - - cmd.AddCommand( - startCmd(), - ) - return cmd -} - -func startCmd() *cobra.Command { - var ( - token string - coderURL string - ) - cmd := &cobra.Command{ - Use: "start --coder-url=[coder_url] --token=[token]", - Short: "starts the coder agent", - Long: "starts the coder agent", - Example: `# start the agent and use CODER_URL and CODER_AGENT_TOKEN env vars - -coder agent start - -# start the agent and connect with a specified url and agent token - -coder agent start --coder-url https://my-coder.com --token xxxx-xxxx -`, - RunE: func(cmd *cobra.Command, args []string) error { - var ( - ctx = cmd.Context() - log = slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelDebug) - ) - if coderURL == "" { - var ok bool - coderURL, ok = os.LookupEnv("CODER_URL") - if !ok { - client, err := newClient(ctx, true) - if err != nil { - return xerrors.New("must login, pass --coder-url flag, or set the CODER_URL env variable") - } - burl := client.BaseURL() - coderURL = burl.String() - } - } - - u, err := url.Parse(coderURL) - if err != nil { - return xerrors.Errorf("parse url: %w", err) - } - - if token == "" { - var ok bool - token, ok = os.LookupEnv("CODER_AGENT_TOKEN") - if !ok { - return xerrors.New("must pass --token or set the CODER_AGENT_TOKEN env variable") - } - } - - log.Info(ctx, "starting wsnet listener", slog.F("coder_access_url", u.String())) - listener, err := wsnet.Listen(ctx, log, wsnet.ListenEndpoint(u, token), token) - if err != nil { - return xerrors.Errorf("listen: %w", err) - } - defer func() { - log.Info(ctx, "closing wsnet listener") - err := listener.Close() - if err != nil { - log.Error(ctx, "close listener", slog.Error(err)) - } - }() - - // Block until user sends SIGINT or SIGTERM - sigs := make(chan os.Signal, 1) - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - <-sigs - - return nil - }, - } - - cmd.Flags().StringVar(&token, "token", "", "coder agent token") - cmd.Flags().StringVar(&coderURL, "coder-url", "", "coder access url") - - return cmd -} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go deleted file mode 100644 index 2c59611a..00000000 --- a/internal/cmd/auth.go +++ /dev/null @@ -1,86 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "net/http" - "net/url" - "os" - - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/internal/version" - "cdr.dev/coder-cli/pkg/clog" -) - -var errNeedLogin = clog.Fatal( - "failed to read session credentials", - clog.Hintf(`did you run "coder login [https://coder.domain.com]"?`), -) - -const tokenEnv = "CODER_TOKEN" -const urlEnv = "CODER_URL" - -func newClient(ctx context.Context, checkVersion bool) (coder.Client, error) { - var ( - err error - sessionToken = os.Getenv(tokenEnv) - rawURL = os.Getenv(urlEnv) - ) - - if sessionToken == "" || rawURL == "" { - sessionToken, err = config.Session.Read() - if err != nil { - return nil, errNeedLogin - } - - rawURL, err = config.URL.Read() - if err != nil { - return nil, errNeedLogin - } - } - - u, err := url.Parse(rawURL) - if err != nil { - return nil, xerrors.Errorf("url malformed: %w try running \"coder login\" with a valid URL", err) - } - - c, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Token: sessionToken, - }) - if err != nil { - return nil, xerrors.Errorf("failed to create new coder.Client: %w", err) - } - - if checkVersion { - var apiVersion string - apiVersion, err = c.APIVersion(ctx) - if apiVersion != "" && !version.VersionsMatch(apiVersion) { - logVersionMismatchError(apiVersion) - } - } - - if err != nil { - var he *coder.HTTPError - if xerrors.As(err, &he) { - if he.StatusCode() == http.StatusUnauthorized { - return nil, xerrors.Errorf("not authenticated: try running \"coder login`\"") - } - } - return nil, err - } - - return c, nil -} - -func logVersionMismatchError(apiVersion string) { - clog.LogWarn( - "version mismatch detected", - fmt.Sprintf("Coder CLI version: %s", version.Version), - fmt.Sprintf("Coder API version: %s", apiVersion), clog.BlankLine, - clog.Tipf("download the appropriate version here: https://github.com/cdr/coder-cli/releases"), - ) -} diff --git a/internal/cmd/ceapi.go b/internal/cmd/ceapi.go deleted file mode 100644 index c58b8161..00000000 --- a/internal/cmd/ceapi.go +++ /dev/null @@ -1,238 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "strings" - - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/pkg/clog" -) - -// Helpers for working with the Coder API. - -// lookupUserOrgs gets a list of orgs the user is apart of. -func lookupUserOrgs(user *coder.User, orgs []coder.Organization) []coder.Organization { - // NOTE: We don't know in advance how many orgs the user is in so we can't pre-alloc. - var userOrgs []coder.Organization - - for _, org := range orgs { - for _, member := range org.Members { - if member.ID != user.ID { - continue - } - // If we found the user in the org, add it to the list and skip to the next org. - userOrgs = append(userOrgs, org) - break - } - } - return userOrgs -} - -// getWorkspaces returns all workspaces for the user. -func getWorkspaces(ctx context.Context, client coder.Client, email string) ([]coder.Workspace, error) { - user, err := client.UserByEmail(ctx, email) - if err != nil { - return nil, xerrors.Errorf("get user: %w", err) - } - - orgs, err := client.Organizations(ctx) - if err != nil { - return nil, xerrors.Errorf("get orgs: %w", err) - } - - orgs = lookupUserOrgs(user, orgs) - - // NOTE: We don't know in advance how many workspaces we have so we can't pre-alloc. - var allWorkspaces []coder.Workspace - - for _, org := range orgs { - workspaces, err := client.UserWorkspacesByOrganization(ctx, user.ID, org.ID) - if err != nil { - return nil, xerrors.Errorf("get workspaces for %s: %w", org.Name, err) - } - - allWorkspaces = append(allWorkspaces, workspaces...) - } - return allWorkspaces, nil -} - -// searchForWorkspace searches a user's workspaces to find the specified workspaceName. If none is found, the haystack of -// workspace names is returned. -func searchForWorkspace(ctx context.Context, client coder.Client, workspaceName, userEmail string) (_ *coder.Workspace, haystack []string, _ error) { - workspaces, err := getWorkspaces(ctx, client, userEmail) - if err != nil { - return nil, nil, xerrors.Errorf("get workspaces: %w", err) - } - - // NOTE: We don't know in advance where we will find the workspace, so we can't pre-alloc. - for _, workspace := range workspaces { - if workspace.Name == workspaceName { - return &workspace, nil, nil - } - // Keep track of what we found for the logs. - haystack = append(haystack, workspace.Name) - } - return nil, haystack, coder.ErrNotFound -} - -// findWorkspace returns a single workspace by name (if it exists.). -func findWorkspace(ctx context.Context, client coder.Client, workspaceName, userEmail string) (*coder.Workspace, error) { - workspace, haystack, err := searchForWorkspace(ctx, client, workspaceName, userEmail) - if err != nil { - return nil, clog.Fatal( - "failed to find workspace", - fmt.Sprintf("workspace %q not found in %q", workspaceName, haystack), - clog.BlankLine, - clog.Tipf("run \"coder workspaces ls\" to view your workspaces"), - ) - } - return workspace, nil -} - -type findImgConf struct { - email string - imgName string - orgName string -} - -func findImg(ctx context.Context, client coder.Client, conf findImgConf) (*coder.Image, error) { - switch { - case conf.email == "": - return nil, xerrors.New("user email unset") - case conf.imgName == "": - return nil, xerrors.New("image name unset") - } - - imgs, err := getImgs(ctx, client, getImgsConf{ - email: conf.email, - orgName: conf.orgName, - }) - if err != nil { - return nil, err - } - - var possibleMatches []coder.Image - - // The user may provide an image thats not an exact match - // to one of their imported images but they may be close. - // We can assist the user by collecting images that contain - // the user provided image flag value as a substring. - for _, img := range imgs { - // If it's an exact match we can just return and exit. - if img.Repository == conf.imgName { - return &img, nil - } - if strings.Contains(img.Repository, conf.imgName) { - possibleMatches = append(possibleMatches, img) - } - } - - if len(possibleMatches) == 0 { - return nil, xerrors.New("image not found - did you forget to import this image?") - } - - lines := []string{clog.Hintf("Did you mean?")} - - for _, img := range possibleMatches { - lines = append(lines, fmt.Sprintf(" %s", img.Repository)) - } - return nil, clog.Fatal( - fmt.Sprintf("image %s not found", conf.imgName), - lines..., - ) -} - -type getImgsConf struct { - email string - orgName string -} - -func getImgs(ctx context.Context, client coder.Client, conf getImgsConf) ([]coder.Image, error) { - u, err := client.UserByEmail(ctx, conf.email) - if err != nil { - return nil, err - } - - orgs, err := client.Organizations(ctx) - if err != nil { - return nil, err - } - - orgs = lookupUserOrgs(u, orgs) - - for _, org := range orgs { - imgs, err := client.OrganizationImages(ctx, org.ID) - if err != nil { - return nil, err - } - // If orgName is set we know the user is a multi-org member - // so we should only return the imported images that beong to the org they specified. - if conf.orgName != "" && conf.orgName == org.Name { - return imgs, nil - } - - if conf.orgName == "" { - // if orgName is unset we know the user is only part of one org. - return imgs, nil - } - } - return nil, xerrors.Errorf("org name %q not found", conf.orgName) -} - -func isMultiOrgMember(ctx context.Context, client coder.Client, email string) (bool, error) { - orgs, err := getUserOrgs(ctx, client, email) - if err != nil { - return false, err - } - return len(orgs) > 1, nil -} - -func getUserOrgs(ctx context.Context, client coder.Client, email string) ([]coder.Organization, error) { - u, err := client.UserByEmail(ctx, email) - if err != nil { - return nil, xerrors.New("email not found") - } - - orgs, err := client.Organizations(ctx) - if err != nil { - return nil, xerrors.New("no organizations found") - } - return lookupUserOrgs(u, orgs), nil -} - -func getWorkspacesByProvider(ctx context.Context, client coder.Client, wpName, userEmail string) ([]coder.Workspace, error) { - wp, err := coderutil.ProviderByName(ctx, client, wpName) - if err != nil { - return nil, err - } - - workspaces, err := client.WorkspacesByWorkspaceProvider(ctx, wp.ID) - if err != nil { - return nil, err - } - - workspaces, err = filterWorkspacesByUser(ctx, client, userEmail, workspaces) - if err != nil { - return nil, err - } - return workspaces, nil -} - -func filterWorkspacesByUser(ctx context.Context, client coder.Client, userEmail string, workspaces []coder.Workspace) ([]coder.Workspace, error) { - user, err := client.UserByEmail(ctx, userEmail) - if err != nil { - return nil, xerrors.Errorf("get user: %w", err) - } - - var filteredWorkspaces []coder.Workspace - for _, workspace := range workspaces { - if workspace.UserID == user.ID { - filteredWorkspaces = append(filteredWorkspaces, workspace) - } - } - return filteredWorkspaces, nil -} diff --git a/internal/cmd/cli_test.go b/internal/cmd/cli_test.go deleted file mode 100644 index ca145a9d..00000000 --- a/internal/cmd/cli_test.go +++ /dev/null @@ -1,169 +0,0 @@ -package cmd - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "io/ioutil" - "net/url" - "os" - "strings" - "testing" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - "cdr.dev/slog/sloggers/slogtest/assert" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/pkg/clog" -) - -var ( - shouldSkipAuthedTests bool = false - testCoderClient coder.Client -) - -func isCI() bool { _, ok := os.LookupEnv("CI"); return ok } - -func skipIfNoAuth(t *testing.T) { - if shouldSkipAuthedTests { - t.Skip("no authentication provided and not in CI, skipping") - } -} - -func init() { - tmpDir, err := ioutil.TempDir("", "coder-cli-config-dir") - if err != nil { - panic(err) - } - config.SetRoot(tmpDir) - - email := os.Getenv("CODER_EMAIL") - password := os.Getenv("CODER_PASSWORD") - rawURL := os.Getenv("CODER_URL") - if email == "" || password == "" || rawURL == "" { - if isCI() { - panic("when run in CI, CODER_EMAIL, CODER_PASSWORD, and CODER_URL are required environment variables") - } - shouldSkipAuthedTests = true - return - } - u, err := url.Parse(rawURL) - if err != nil { - panic("invalid CODER_URL: " + err.Error()) - } - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: u, - Email: email, - Password: password, - }) - if err != nil { - panic("new client: " + err.Error()) - } - testCoderClient = client - if err := config.URL.Write(rawURL); err != nil { - panic("write config url: " + err.Error()) - } - if err := config.Session.Write(client.Token()); err != nil { - panic("write config token: " + err.Error()) - } -} - -type result struct { - outBuffer *bytes.Buffer - errBuffer *bytes.Buffer - exitErr error -} - -func (r result) success(t *testing.T) { - t.Helper() - assert.Success(t, "execute command", r.exitErr) -} - -func (r result) error(t *testing.T) { - t.Helper() - assert.Error(t, "execute command", r.exitErr) -} - -//nolint -func (r result) stdoutContains(t *testing.T, substring string) { - t.Helper() - if !strings.Contains(r.outBuffer.String(), substring) { - slogtest.Fatal(t, "stdout contains substring", slog.F("substring", substring), slog.F("stdout", r.outBuffer.String())) - } -} - -func (r result) stdoutUnmarshals(t *testing.T, target interface{}) { - t.Helper() - err := json.Unmarshal(r.outBuffer.Bytes(), target) - assert.Success(t, "unmarshal json", err) -} - -//nolint -func (r result) stdoutEmpty(t *testing.T) { - t.Helper() - assert.Equal(t, "stdout empty", "", r.outBuffer.String()) -} - -//nolint -func (r result) stderrEmpty(t *testing.T) { - t.Helper() - assert.Equal(t, "stderr empty", "", r.errBuffer.String()) -} - -//nolint -func (r result) stderrContains(t *testing.T, substring string) { - t.Helper() - if !strings.Contains(r.errBuffer.String(), substring) { - slogtest.Fatal(t, "stderr contains substring", slog.F("substring", substring), slog.F("stderr", r.errBuffer.String())) - } -} - -//nolint -func (r result) clogError(t *testing.T) clog.CLIError { - t.Helper() - var cliErr clog.CLIError - if !xerrors.As(r.exitErr, &cliErr) { - slogtest.Fatal(t, "expected clog error, none found", slog.Error(r.exitErr), slog.F("type", fmt.Sprintf("%T", r.exitErr))) - } - slogtest.Debug(t, "clog error", slog.F("message", cliErr.String())) - return cliErr -} - -//nolint -func execute(t *testing.T, in io.Reader, args ...string) result { - cmd := Make() - - var outStream bytes.Buffer - var errStream bytes.Buffer - - cmd.SetArgs(args) - - cmd.SetIn(in) - cmd.SetOut(&outStream) - cmd.SetErr(&errStream) - - // TODO: this *needs* to be moved to function scoped writer arg. As is, - // this prevents tests from running in parallel. - clog.SetOutput(&errStream) - - err := cmd.Execute() - - slogtest.Debug(t, "execute command", - slog.F("out_buffer", outStream.String()), - slog.F("err_buffer", errStream.String()), - slog.F("args", args), - slog.F("execute_error", err), - ) - if err != nil { - clog.Log(err) - } - return result{ - outBuffer: &outStream, - errBuffer: &errStream, - exitErr: err, - } -} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go deleted file mode 100644 index 26df6bc4..00000000 --- a/internal/cmd/cmd.go +++ /dev/null @@ -1,121 +0,0 @@ -// Package cmd constructs all subcommands for coder-cli. -package cmd - -import ( - "github.com/spf13/cobra" - "github.com/spf13/cobra/doc" - - "cdr.dev/coder-cli/internal/x/xcobra" -) - -// verbose is a global flag for specifying that a command should give verbose output. -var verbose bool = false - -// Make constructs the "coder" root command. -func Make() *cobra.Command { - app := &cobra.Command{ - Use: "coder", - Short: "coder provides a CLI for working with an existing Coder installation", - SilenceErrors: true, - SilenceUsage: true, - DisableAutoGenTag: true, - } - - app.AddCommand( - agentCmd(), - completionCmd(), - configSSHCmd(), - envCmd(), // DEPRECATED. - genDocsCmd(app), - imgsCmd(), - loginCmd(), - logoutCmd(), - providersCmd(), - resourceCmd(), - satellitesCmd(), - sshCmd(), - syncCmd(), - tagsCmd(), - tokensCmd(), - tunnelCmd(), - urlCmd(), - usersCmd(), - workspacesCmd(), - ) - app.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "show verbose output") - return app -} - -func genDocsCmd(rootCmd *cobra.Command) *cobra.Command { - return &cobra.Command{ - Use: "gen-docs [dir_path]", - Short: "Generate a markdown documentation tree for the root command.", - Args: xcobra.ExactArgs(1), - Example: "coder gen-docs ./docs", - Hidden: true, - RunE: func(_ *cobra.Command, args []string) error { - return doc.GenMarkdownTree(rootCmd, args[0]) - }, - } -} - -// reference: https://github.com/spf13/cobra/blob/master/shell_completions.md -func completionCmd() *cobra.Command { - return &cobra.Command{ - Use: "completion [bash|zsh|fish|powershell]", - Short: "Generate completion script", - Example: `coder completion fish > ~/.config/fish/completions/coder.fish -coder completion zsh > "${fpath[1]}/_coder" - -Linux: - $ coder completion bash > /etc/bash_completion.d/coder -MacOS: - $ coder completion bash > /usr/local/etc/bash_completion.d/coder`, - Long: `To load completions: - -Bash: - -$ source <(coder completion bash) - -To load completions for each session, execute once: -Linux: - $ coder completion bash > /etc/bash_completion.d/coder -MacOS: - $ coder completion bash > /usr/local/etc/bash_completion.d/coder - -Zsh: - -If shell completion is not already enabled in your workspace you will need -to enable it. You can execute the following once: - -$ echo "autoload -U compinit; compinit" >> ~/.zshrc - -To load completions for each session, execute once: -$ coder completion zsh > "${fpath[1]}/_coder" - -You will need to start a new shell for this setup to take effect. - -Fish: - -$ coder completion fish | source - -To load completions for each session, execute once: -$ coder completion fish > ~/.config/fish/completions/coder.fish -`, - DisableFlagsInUseLine: true, - ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, - Args: cobra.ExactValidArgs(1), - Run: func(cmd *cobra.Command, args []string) { - switch args[0] { - case "bash": - _ = cmd.Root().GenBashCompletion(cmd.OutOrStdout()) // Best effort. - case "zsh": - _ = cmd.Root().GenZshCompletion(cmd.OutOrStdout()) // Best effort. - case "fish": - _ = cmd.Root().GenFishCompletion(cmd.OutOrStdout(), true) // Best effort. - case "powershell": - _ = cmd.Root().GenPowerShellCompletion(cmd.OutOrStdout()) // Best effort. - } - }, - } -} diff --git a/internal/cmd/configssh.go b/internal/cmd/configssh.go deleted file mode 100644 index 8818c9e2..00000000 --- a/internal/cmd/configssh.go +++ /dev/null @@ -1,307 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io/ioutil" - "os" - "os/user" - "path/filepath" - "runtime" - "sort" - "strings" - - "github.com/cli/safeexec" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/pkg/clog" -) - -const sshStartToken = "# ------------START-CODER-ENTERPRISE-----------" -const sshStartMessage = `# The following has been auto-generated by "coder config-ssh" -# to make accessing your Coder workspaces easier. -# -# To remove this blob, run: -# -# coder config-ssh --remove -# -# You should not hand-edit this section, unless you are deleting it.` -const sshEndToken = "# ------------END-CODER-ENTERPRISE------------" - -func configSSHCmd() *cobra.Command { - var ( - configpath string - remove = false - additionalOptions []string - ) - - cmd := &cobra.Command{ - Use: "config-ssh", - Short: "Configure SSH to access Coder workspaces", - Long: "Inject the proper OpenSSH configuration into your local SSH config file.", - RunE: configSSH(&configpath, &remove, &additionalOptions), - } - cmd.Flags().StringVar(&configpath, "filepath", filepath.Join("~", ".ssh", "config"), "override the default path of your ssh config file") - cmd.Flags().StringSliceVarP(&additionalOptions, "option", "o", []string{}, "additional options injected in the ssh config (ex. disable caching with \"-o ControlPath=none\")") - cmd.Flags().BoolVar(&remove, "remove", false, "remove the auto-generated Coder ssh config") - - return cmd -} - -func configSSH(configpath *string, remove *bool, additionalOptions *[]string) func(cmd *cobra.Command, _ []string) error { - return func(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - usr, err := user.Current() - if err != nil { - return xerrors.Errorf("get user home directory: %w", err) - } - - privateKeyFilepath := filepath.Join(usr.HomeDir, ".ssh", "coder_enterprise") - - if strings.HasPrefix(*configpath, "~") { - *configpath = strings.Replace(*configpath, "~", usr.HomeDir, 1) - } - - currentConfig, err := readStr(*configpath) - if os.IsNotExist(err) { - // SSH configs are not always already there. - currentConfig = "" - } else if err != nil { - return xerrors.Errorf("read ssh config file %q: %w", *configpath, err) - } - - currentConfig, didRemoveConfig := removeOldConfig(currentConfig) - if *remove { - if !didRemoveConfig { - return xerrors.Errorf("the Coder ssh configuration section could not be safely deleted or does not exist") - } - - err = writeStr(*configpath, currentConfig) - if err != nil { - return xerrors.Errorf("write to ssh config file %q: %s", *configpath, err) - } - _ = os.Remove(privateKeyFilepath) - - return nil - } - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - user, err := client.Me(ctx) - if err != nil { - return xerrors.Errorf("fetch username: %w", err) - } - - workspaces, err := getWorkspaces(ctx, client, coder.Me) - if err != nil { - return err - } - if len(workspaces) < 1 { - return xerrors.New("no workspaces found") - } - - workspacesWithProviders, err := coderutil.WorkspacesWithProvider(ctx, client, workspaces) - if err != nil { - return xerrors.Errorf("resolve workspace workspace providers: %w", err) - } - - if !sshAvailable(workspacesWithProviders) { - return xerrors.New("SSH is disabled or not available for any workspaces in your Coder deployment.") - } - - binPath, err := binPath() - if err != nil { - return xerrors.Errorf("Failed to get executable path: %w", err) - } - - newConfig := makeNewConfigs(binPath, workspacesWithProviders, privateKeyFilepath, *additionalOptions) - - err = os.MkdirAll(filepath.Dir(*configpath), os.ModePerm) - if err != nil { - return xerrors.Errorf("make configuration directory: %w", err) - } - err = writeStr(*configpath, currentConfig+newConfig) - if err != nil { - return xerrors.Errorf("write new configurations to ssh config file %q: %w", *configpath, err) - } - err = writeSSHKey(ctx, client, privateKeyFilepath) - if err != nil { - if !xerrors.Is(err, os.ErrPermission) { - return xerrors.Errorf("write ssh key: %w", err) - } - fmt.Printf("Your private ssh key already exists at \"%s\"\nYou may need to remove the existing private key file and re-run this command\n\n", privateKeyFilepath) - } else { - fmt.Printf("Your private ssh key was written to \"%s\"\n", privateKeyFilepath) - } - - writeSSHUXState(ctx, client, user.ID, workspaces) - fmt.Printf("An auto-generated ssh config was written to \"%s\"\n", *configpath) - fmt.Println("You should now be able to ssh into your workspace") - fmt.Printf("For example, try running\n\n\t$ ssh coder.%s\n\n", workspaces[0].Name) - return nil - } -} - -// binPath returns the path to the coder binary suitable for use in ssh -// ProxyCommand. -func binPath() (string, error) { - exePath, err := os.Executable() - if err != nil { - return "", xerrors.Errorf("get executable path: %w", err) - } - - // On Windows, the coder-cli executable must be in $PATH for both Msys2/Git - // Bash and OpenSSH for Windows (used by Powershell and VS Code) to function - // correctly. Check if the current executable is in $PATH, and warn the user - // if it isn't. - if runtime.GOOS == "windows" { - binName := filepath.Base(exePath) - - // We use safeexec instead of os/exec because os/exec returns paths in - // the current working directory, which we will run into very often when - // looking for our own path. - pathPath, err := safeexec.LookPath(binName) - if err != nil { - clog.LogWarn( - "The current executable is not in $PATH.", - "This may lead to problems connecting to your workspace via SSH.", - fmt.Sprintf("Please move %q to a location in your $PATH (such as System32) and run `%s config-ssh` again.", binName, binName), - ) - // Return the exePath so SSH at least works outside of Msys2. - return exePath, nil - } - - // Warn the user if the current executable is not the same as the one in - // $PATH. - if filepath.Clean(pathPath) != filepath.Clean(exePath) { - clog.LogWarn( - "The current executable path does not match the executable path found in $PATH.", - "This may lead to problems connecting to your workspace via SSH.", - fmt.Sprintf("\t Current executable path: %q", exePath), - fmt.Sprintf("\tExecutable path in $PATH: %q", pathPath), - ) - } - - return binName, nil - } - - // On platforms other than Windows we can use the full path to the binary. - return exePath, nil -} - -// removeOldConfig removes the old ssh configuration from the user's sshconfig. -// Returns true if the config was modified. -func removeOldConfig(config string) (string, bool) { - startIndex := strings.Index(config, sshStartToken) - endIndex := strings.Index(config, sshEndToken) - - if startIndex == -1 || endIndex == -1 { - return config, false - } - if startIndex == 0 { - return config[endIndex+len(sshEndToken)+1:], true - } - return config[:startIndex-1] + config[endIndex+len(sshEndToken)+1:], true -} - -// sshAvailable returns true if SSH is available for at least one workspace. -func sshAvailable(workspaces []coderutil.WorkspaceWithWorkspaceProvider) bool { - for _, workspace := range workspaces { - if workspace.WorkspaceProvider.SSHEnabled { - return true - } - } - return false -} - -func writeSSHKey(ctx context.Context, client coder.Client, privateKeyPath string) error { - key, err := client.SSHKey(ctx) - if err != nil { - return err - } - return ioutil.WriteFile(privateKeyPath, []byte(key.PrivateKey), 0600) -} - -func makeNewConfigs(binPath string, workspaces []coderutil.WorkspaceWithWorkspaceProvider, privateKeyFilepath string, additionalOptions []string) string { - newConfig := fmt.Sprintf("\n%s\n%s\n\n", sshStartToken, sshStartMessage) - - sort.Slice(workspaces, func(i, j int) bool { return workspaces[i].Workspace.Name < workspaces[j].Workspace.Name }) - - for _, workspace := range workspaces { - if !workspace.WorkspaceProvider.SSHEnabled { - clog.LogWarn(fmt.Sprintf("SSH is not enabled for workspace provider %q", workspace.WorkspaceProvider.Name), - clog.BlankLine, - clog.Tipf("ask an infrastructure administrator to enable SSH for this workspace provider"), - ) - continue - } - - newConfig += makeSSHConfig(binPath, workspace.Workspace.Name, privateKeyFilepath, additionalOptions) - } - newConfig += fmt.Sprintf("\n%s\n", sshEndToken) - - return newConfig -} - -func makeSSHConfig(binPath, workspaceName, privateKeyFilepath string, additionalOptions []string) string { - // Custom user options come first to maximizessh customization. - options := []string{} - if len(additionalOptions) > 0 { - options = []string{ - "# Custom options. Duplicated values will always prefer the first!", - } - options = append(options, additionalOptions...) - options = append(options, "# End custom options.") - } - options = append(options, - fmt.Sprintf("HostName coder.%s", workspaceName), - fmt.Sprintf("ProxyCommand %q tunnel %s 12213 stdio", binPath, workspaceName), - "StrictHostKeyChecking no", - "ConnectTimeout=0", - "IdentitiesOnly yes", - fmt.Sprintf("IdentityFile=%q", privateKeyFilepath), - ) - - if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - options = append(options, - "ControlMaster auto", - "ControlPath ~/.ssh/.connection-%r@%h:%p", - "ControlPersist 600", - ) - } - - return fmt.Sprintf("Host coder.%s\n\t%s\n\n", workspaceName, strings.Join(options, "\n\t")) -} - -func writeStr(filename, data string) error { - return ioutil.WriteFile(filename, []byte(data), 0777) -} - -func readStr(filename string) (string, error) { - contents, err := ioutil.ReadFile(filename) - if err != nil { - return "", err - } - return string(contents), nil -} - -func writeSSHUXState(ctx context.Context, client coder.Client, userID string, workspaces []coder.Workspace) { - // Create a map of workspace.ID -> true to indicate to the web client that all - // current workspaces have SSH configured - cliSSHConfigured := make(map[string]bool) - for _, workspace := range workspaces { - cliSSHConfigured[workspace.ID] = true - } - // Update UXState that coder config-ssh has been run by the currently - // authenticated user - err := client.UpdateUXState(ctx, userID, map[string]interface{}{"cliSSHConfigured": cliSSHConfigured}) - if err != nil { - clog.LogWarn("The Coder web client may not recognize that you've configured SSH.") - } -} diff --git a/internal/cmd/devurls_test.go b/internal/cmd/devurls_test.go deleted file mode 100644 index 3e3c2bd4..00000000 --- a/internal/cmd/devurls_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package cmd - -import ( - "testing" -) - -func Test_devurls(t *testing.T) { - skipIfNoAuth(t) - res := execute(t, nil, "urls", "ls") - res.error(t) -} diff --git a/internal/cmd/errors.go b/internal/cmd/errors.go deleted file mode 100644 index dce13918..00000000 --- a/internal/cmd/errors.go +++ /dev/null @@ -1,71 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" -) - -// handleAPIError attempts to convert an api error into a more detailed clog error. -// If it cannot, it will return the original error. -func handleAPIError(origError error) error { - var httpError *coder.HTTPError - if !xerrors.As(origError, &httpError) { - return origError // Return the original - } - - ae, err := httpError.Payload() - if err != nil { - return origError // Return the original - } - - switch ae.Err.Code { - case "wac_template": // template parse errors - type templatePayload struct { - ErrorType string `json:"error_type"` - Msgs []string `json:"messages"` - } - - var p templatePayload - err := json.Unmarshal(ae.Err.Details, &p) - if err != nil { - return origError - } - - return clog.Error(p.ErrorType, p.Msgs...) - case "verbose": - type verbosePayload struct { - Verbose string `json:"verbose"` - } - var p verbosePayload - err := json.Unmarshal(ae.Err.Details, &p) - if err != nil { - return origError - } - - return clog.Error(origError.Error(), p.Verbose) - case "precondition": - type preconditionPayload struct { - Error string `json:"error"` - Message string `json:"message"` - Solution string `json:"solution"` - } - - var p preconditionPayload - err := json.Unmarshal(ae.Err.Details, &p) - if err != nil { - return origError - } - - return clog.Error(fmt.Sprintf("Precondition Error : Status Code=%d", httpError.StatusCode()), - p.Message, - clog.BlankLine, - clog.Tipf(p.Solution)) - } - - return origError // Return the original -} diff --git a/internal/cmd/images.go b/internal/cmd/images.go deleted file mode 100644 index ccb68ee5..00000000 --- a/internal/cmd/images.go +++ /dev/null @@ -1,88 +0,0 @@ -package cmd - -import ( - "encoding/json" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func imgsCmd() *cobra.Command { - var user string - - cmd := &cobra.Command{ - Use: "images", - Short: "Manage Coder images", - Long: "Manage existing images and/or import new ones.", - } - - cmd.PersistentFlags().StringVar(&user, "user", coder.Me, "Specifies the user by email") - cmd.AddCommand(lsImgsCommand(&user)) - return cmd -} - -func lsImgsCommand(user *string) *cobra.Command { - var ( - orgName string - outputFmt string - ) - - cmd := &cobra.Command{ - Use: "ls", - Short: "list all images available to the active user", - Long: "List all Coder images available to the active user.", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - imgs, err := getImgs(ctx, client, - getImgsConf{ - email: *user, - orgName: orgName, - }, - ) - - if err != nil { - return err - } - - if len(imgs) < 1 { - clog.LogInfo("no images found") - imgs = []coder.Image{} // ensures that json output still marshals - } - - switch outputFmt { - case jsonOutput: - enc := json.NewEncoder(cmd.OutOrStdout()) - // pretty print the json - enc.SetIndent("", "\t") - - if err := enc.Encode(imgs); err != nil { - return xerrors.Errorf("write images as JSON: %w", err) - } - return nil - case humanOutput: - err = tablewriter.WriteTable(cmd.OutOrStdout(), len(imgs), func(i int) interface{} { - return imgs[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - return nil - default: - return xerrors.Errorf("%q is not a supported value for --output", outputFmt) - } - }, - } - cmd.Flags().StringVar(&orgName, "org", "", "organization name") - cmd.Flags().StringVar(&outputFmt, "output", humanOutput, "human | json") - return cmd -} diff --git a/internal/cmd/images_test.go b/internal/cmd/images_test.go deleted file mode 100644 index b5823ff6..00000000 --- a/internal/cmd/images_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package cmd - -import ( - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -func Test_images(t *testing.T) { - res := execute(t, nil, "images", "--help") - res.success(t) - - res = execute(t, nil, "images", "ls") - res.success(t) - - var images []coder.Image - res = execute(t, nil, "images", "ls", "--output=json") - res.success(t) - res.stdoutUnmarshals(t, &images) - assert.True(t, "more than 0 images", len(images) > 0) - - res = execute(t, nil, "images", "ls", "--org=doesntexist") - res.error(t) - res.stderrContains(t, "org name \"doesntexist\" not found") -} diff --git a/internal/cmd/login.go b/internal/cmd/login.go deleted file mode 100644 index a706259c..00000000 --- a/internal/cmd/login.go +++ /dev/null @@ -1,115 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "net/url" - "strings" - - "github.com/pkg/browser" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/internal/version" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" -) - -func loginCmd() *cobra.Command { - return &cobra.Command{ - Use: "login [Coder URL eg. https://my.coder.domain/]", - Short: "Authenticate this client for future operations", - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // Pull the URL from the args and do some sanity check. - rawURL := args[0] - if rawURL == "" || !strings.HasPrefix(rawURL, "http") { - return xerrors.Errorf("invalid URL") - } - u, err := url.Parse(rawURL) - if err != nil { - return xerrors.Errorf("parse url: %w", err) - } - // Remove the trailing '/' if any. - u.Path = strings.TrimSuffix(u.Path, "/") - - // From this point, the commandline is correct. - // Don't return errors as it would print the usage. - - if err := login(cmd, u); err != nil { - return xerrors.Errorf("login error: %w", err) - } - return nil - }, - } -} - -// storeConfig writes the workspace URL and session token to the local config directory. -// The config lib will handle the local config path lookup and creation. -func storeConfig(workspaceURL *url.URL, sessionToken string, urlCfg, sessionCfg config.File) error { - if err := urlCfg.Write(workspaceURL.String()); err != nil { - return xerrors.Errorf("store workspace url: %w", err) - } - if err := sessionCfg.Write(sessionToken); err != nil { - return xerrors.Errorf("store session token: %w", err) - } - return nil -} - -func login(cmd *cobra.Command, workspaceURL *url.URL) error { - authURL := *workspaceURL - authURL.Path = workspaceURL.Path + "/internal-auth" - q := authURL.Query() - q.Add("show_token", "true") - authURL.RawQuery = q.Encode() - - if err := browser.OpenURL(authURL.String()); err != nil { - fmt.Printf("Open the following in your browser:\n\n\t%s\n\n", authURL.String()) - } else { - fmt.Printf("Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String()) - } - - fmt.Print("Paste token here: ") - var token string - scanner := bufio.NewScanner(cmd.InOrStdin()) - _ = scanner.Scan() - token = scanner.Text() - if err := scanner.Err(); err != nil { - return xerrors.Errorf("reading standard input: %w", err) - } - - if err := pingAPI(cmd.Context(), workspaceURL, token); err != nil { - return xerrors.Errorf("ping API with credentials: %w", err) - } - if err := storeConfig(workspaceURL, token, config.URL, config.Session); err != nil { - return xerrors.Errorf("store auth: %w", err) - } - clog.LogSuccess("logged in") - return nil -} - -// pingAPI creates a client from the given url/token and try to exec an api call. -// Not using the SDK as we want to verify the url/token pair before storing the config files. -func pingAPI(ctx context.Context, workspaceURL *url.URL, token string) error { - client, err := coder.NewClient(coder.ClientOptions{ - BaseURL: workspaceURL, - Token: token, - }) - if err != nil { - return xerrors.Errorf("failed to create coder.Client: %w", err) - } - - if apiVersion, err := client.APIVersion(ctx); err == nil { - if apiVersion != "" && !version.VersionsMatch(apiVersion) { - logVersionMismatchError(apiVersion) - } - } - _, err = client.Me(ctx) - if err != nil { - return xerrors.Errorf("call api: %w", err) - } - return nil -} diff --git a/internal/cmd/logout.go b/internal/cmd/logout.go deleted file mode 100644 index fd864aa1..00000000 --- a/internal/cmd/logout.go +++ /dev/null @@ -1,32 +0,0 @@ -package cmd - -import ( - "os" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/internal/config" - "cdr.dev/coder-cli/pkg/clog" -) - -func logoutCmd() *cobra.Command { - return &cobra.Command{ - Use: "logout", - Short: "Remove local authentication credentials if any exist", - RunE: logout, - } -} - -func logout(_ *cobra.Command, _ []string) error { - err := config.Session.Delete() - if err != nil { - if os.IsNotExist(err) { - clog.LogInfo("no active session") - return nil - } - return xerrors.Errorf("delete session: %w", err) - } - clog.LogSuccess("logged out") - return nil -} diff --git a/internal/cmd/providers.go b/internal/cmd/providers.go deleted file mode 100644 index 5081df2b..00000000 --- a/internal/cmd/providers.go +++ /dev/null @@ -1,319 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" - - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/internal/x/xcobra" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func providersCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "providers", - Short: "Interact with Coder workspace providers", - Long: "Perform operations on the Coder Workspace Providers for the platform.", - Hidden: true, - } - - cmd.AddCommand( - createProviderCmd(), - listProviderCmd(), - deleteProviderCmd(), - cordonProviderCmd(), - unCordonProviderCmd(), - renameProviderCmd(), - ) - return cmd -} - -func createProviderCmd() *cobra.Command { - var ( - hostname string - clusterAddress string - ) - cmd := &cobra.Command{ - Use: "create [name] --hostname=[hostname] --cluster-address=[clusterAddress]", - Args: xcobra.ExactArgs(1), - Short: "create a new workspace provider.", - Long: "Create a new Coder workspace provider.", - Example: `# create a new workspace provider in a pending state - -coder providers create my-provider --hostname=https://provider.example.com --cluster-address=https://255.255.255.255`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - version, err := client.APIVersion(ctx) - if err != nil { - return xerrors.Errorf("get application version: %w", err) - } - - cemanagerURL := client.BaseURL() - ingressHost, err := url.Parse(hostname) - if err != nil { - return xerrors.Errorf("parse hostname: %w", err) - } - - if cemanagerURL.Scheme != ingressHost.Scheme { - return xerrors.Errorf("Coder access url and hostname must have matching protocols: coder access url: %s, workspace provider hostname: %s", cemanagerURL.String(), ingressHost.String()) - } - - // ExactArgs(1) ensures our name value can't panic on an out of bounds. - createReq := &coder.CreateWorkspaceProviderReq{ - Name: args[0], - Type: coder.WorkspaceProviderKubernetes, - Hostname: hostname, - ClusterAddress: clusterAddress, - } - - wp, err := client.CreateWorkspaceProvider(ctx, *createReq) - if err != nil { - return xerrors.Errorf("create workspace provider: %w", err) - } - - var sslNote string - if ingressHost.Scheme == "https" { - sslNote = ` -NOTE: Since the hostname provided is using https you must ensure the deployment -has a valid SSL certificate. See https://coder.com/docs/guides/ssl-certificates -for more information.` - } - - clog.LogSuccess(fmt.Sprintf(` -Created workspace provider "%s" -`, createReq.Name)) - _ = tablewriter.WriteTable(cmd.OutOrStdout(), 1, func(i int) interface{} { - return *wp - }) - _, _ = fmt.Fprint(cmd.OutOrStdout(), ` -Now that the workspace provider is provisioned, it must be deployed into the cluster. To learn more, -visit https://coder.com/docs/workspace-providers/deployment - -When connected to the cluster you wish to deploy onto, use the following helm command: - -helm upgrade coder-workspace-provider coder/workspace-provider \ - --version=`+version+` \ - --atomic \ - --install \ - --force \ - --set envproxy.token=`+wp.EnvproxyToken+` \ - --set envproxy.accessURL=`+ingressHost.String()+` \ - --set ingress.host=`+ingressHost.Hostname()+` \ - --set envproxy.clusterAddress=`+clusterAddress+` \ - --set cemanager.accessURL=`+cemanagerURL.String()+` -`+sslNote+` - -WARNING: The 'envproxy.token' is a secret value that authenticates the workspace provider, -make sure not to share this token or make it public. - -Other values can be set on the helm chart to further customize the deployment, see -https://github.com/cdr/enterprise-helm/blob/workspace-providers-envproxy-only/README.md -`) - - return nil - }, - } - - cmd.Flags().StringVar(&hostname, "hostname", "", "workspace provider hostname") - cmd.Flags().StringVar(&clusterAddress, "cluster-address", "", "kubernetes cluster apiserver endpoint") - _ = cmd.MarkFlagRequired("hostname") - _ = cmd.MarkFlagRequired("cluster-address") - return cmd -} - -func listProviderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ls", - Short: "list workspace providers.", - Long: "List all Coder workspace providers.", - Example: `# list workspace providers -coder providers ls`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - wps, err := client.WorkspaceProviders(ctx) - if err != nil { - return xerrors.Errorf("list workspace providers: %w", err) - } - - err = tablewriter.WriteTable(cmd.OutOrStdout(), len(wps.Kubernetes), func(i int) interface{} { - return wps.Kubernetes[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - return nil - }, - } - return cmd -} - -func deleteProviderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "rm [workspace_provider_name]", - Short: "remove a workspace provider.", - Long: "Remove an existing Coder workspace provider by name.", - Example: `# remove an existing workspace provider by name -coder providers rm my-workspace-provider`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - wps, err := client.WorkspaceProviders(ctx) - if err != nil { - return xerrors.Errorf("listing workspace providers: %w", err) - } - - egroup := clog.LoggedErrGroup() - for _, wpName := range args { - name := wpName - egroup.Go(func() error { - var id string - for _, wp := range wps.Kubernetes { - if wp.Name == name { - id = wp.ID - } - } - if id == "" { - return clog.Error( - fmt.Sprintf(`failed to remove workspace provider "%s"`, name), - clog.Causef(`no workspace provider found by name "%s"`, name), - ) - } - - err = client.DeleteWorkspaceProviderByID(ctx, id) - if err != nil { - return clog.Error( - fmt.Sprintf(`failed to remove workspace provider "%s"`, name), - clog.Causef(err.Error()), - ) - } - - clog.LogSuccess(fmt.Sprintf(`removed workspace provider with name "%s"`, name)) - - return nil - }) - } - return egroup.Wait() - }, - } - return cmd -} - -func cordonProviderCmd() *cobra.Command { - var reason string - - cmd := &cobra.Command{ - Use: "cordon [workspace_provider_name]", - Args: xcobra.ExactArgs(1), - Short: "cordon a workspace provider.", - Long: "Prevent an existing Coder workspace provider from supporting any additional workspaces.", - Example: `# cordon an existing workspace provider by name -coder providers cordon my-workspace-provider --reason "limit cloud clost"`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - wpName := args[0] - provider, err := coderutil.ProviderByName(ctx, client, wpName) - if err != nil { - return err - } - - if err := client.CordonWorkspaceProvider(ctx, provider.ID, reason); err != nil { - return err - } - clog.LogSuccess(fmt.Sprintf("provider %q successfully cordoned - you can no longer create workspaces on this provider without uncordoning first", wpName)) - return nil - }, - } - cmd.Flags().StringVar(&reason, "reason", "", "reason for cordoning the provider") - _ = cmd.MarkFlagRequired("reason") - return cmd -} - -func unCordonProviderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "uncordon [workspace_provider_name]", - Args: xcobra.ExactArgs(1), - Short: "uncordon a workspace provider.", - Long: "Set a currently cordoned provider as ready; enabling it to continue provisioning resources for new workspaces.", - Example: `# uncordon an existing workspace provider by name -coder providers uncordon my-workspace-provider`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - wpName := args[0] - provider, err := coderutil.ProviderByName(ctx, client, wpName) - if err != nil { - return err - } - - if err := client.UnCordonWorkspaceProvider(ctx, provider.ID); err != nil { - return err - } - clog.LogSuccess(fmt.Sprintf("provider %q successfully uncordoned - you can now create workspaces on this provider", wpName)) - return nil - }, - } - return cmd -} - -func renameProviderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "rename [old_name] [new_name]", - Args: xcobra.ExactArgs(2), - Short: "rename a workspace provider.", - Long: "Changes the name field of an existing workspace provider.", - Example: `# rename a workspace provider from 'built-in' to 'us-east-1' -coder providers rename build-in us-east-1`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - oldName := args[0] - newName := args[1] - provider, err := coderutil.ProviderByName(ctx, client, oldName) - if err != nil { - return err - } - - if err := client.RenameWorkspaceProvider(ctx, provider.ID, newName); err != nil { - return err - } - clog.LogSuccess(fmt.Sprintf("provider %s successfully renamed to %s", oldName, newName)) - return nil - }, - } - return cmd -} diff --git a/internal/cmd/providers_test.go b/internal/cmd/providers_test.go deleted file mode 100644 index 685e129b..00000000 --- a/internal/cmd/providers_test.go +++ /dev/null @@ -1,11 +0,0 @@ -package cmd - -import ( - "testing" -) - -func Test_providers_ls(t *testing.T) { - skipIfNoAuth(t) - res := execute(t, nil, "providers", "ls") - res.success(t) -} diff --git a/internal/cmd/rebuild.go b/internal/cmd/rebuild.go deleted file mode 100644 index 80a4521c..00000000 --- a/internal/cmd/rebuild.go +++ /dev/null @@ -1,186 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "strings" - "time" - - "github.com/briandowns/spinner" - "github.com/fatih/color" - "github.com/manifoldco/promptui" - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" -) - -func rebuildWorkspaceCommand() *cobra.Command { - var follow bool - var force bool - var user string - cmd := &cobra.Command{ - Use: "rebuild [workspace_name]", - Short: "rebuild a Coder workspace", - Args: xcobra.ExactArgs(1), - Example: `coder workspaces rebuild front-end-workspace --follow -coder workspaces rebuild backend-workspace --force`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspace, err := findWorkspace(ctx, client, args[0], user) - if err != nil { - return err - } - - if !force && workspace.LatestStat.ContainerStatus == coder.WorkspaceOn { - _, err = (&promptui.Prompt{ - Label: fmt.Sprintf("Rebuild workspace %q? (will destroy any work outside of your home directory)", workspace.Name), - IsConfirm: true, - }).Run() - if err != nil { - return clog.Fatal( - "failed to confirm prompt", clog.BlankLine, - clog.Tipf(`use "--force" to rebuild without a confirmation prompt`), - ) - } - } - - if err = client.RebuildWorkspace(ctx, workspace.ID); err != nil { - return err - } - if follow { - if err = trailBuildLogs(ctx, client, workspace.ID); err != nil { - return err - } - } else { - clog.LogSuccess( - "successfully started rebuild", - clog.Tipf("run \"coder workspaces watch-build %s\" to follow the build logs", workspace.Name), - ) - } - return nil - }, - } - - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - cmd.Flags().BoolVar(&follow, "follow", false, "follow build log after initiating rebuild") - cmd.Flags().BoolVar(&force, "force", false, "force rebuild without showing a confirmation prompt") - return cmd -} - -// trailBuildLogs follows the build log for a given workspace and prints the staged -// output with loaders and success/failure indicators for each stage. -func trailBuildLogs(ctx context.Context, client coder.Client, workspaceID string) error { - const check = "✅" - const failure = "❌" - - newSpinner := func() *spinner.Spinner { return spinner.New(spinner.CharSets[11], 100*time.Millisecond) } - - // this tells us whether to show dynamic loaders when printing output - isTerminal := showInteractiveOutput - - logs, err := client.FollowWorkspaceBuildLog(ctx, workspaceID) - if err != nil { - return err - } - - var s *spinner.Spinner - for l := range logs { - if l.Err != nil { - return l.Err - } - - logTime := l.BuildLog.Time.Local() - msg := fmt.Sprintf("%s %s", logTime.Format(time.RFC3339), l.BuildLog.Msg) - - switch l.BuildLog.Type { - case coder.BuildLogTypeStart: - // the FE uses this to reset the UI - // the CLI doesn't need to do anything here given that we only append to the trail - - case coder.BuildLogTypeStage: - if !isTerminal { - fmt.Println(msg) - continue - } - - if s != nil { - s.Stop() - fmt.Print("\n") - } - - s = newSpinner() - s.Suffix = fmt.Sprintf(" -- %s", msg) - s.FinalMSG = fmt.Sprintf("%s -- %s", check, msg) - s.Start() - - case coder.BuildLogTypeSubstage: - // TODO(@f0ssel) add verbose substage printing - if !verbose { - continue - } - - case coder.BuildLogTypeError: - if !isTerminal { - fmt.Println(msg) - continue - } - - if s != nil { - s.FinalMSG = fmt.Sprintf("%s %s", failure, strings.TrimPrefix(s.Suffix, " ")) - s.Stop() - fmt.Print("\n") - } - - s = newSpinner() - s.Suffix = color.RedString(" -- %s", msg) - s.FinalMSG = color.RedString("%s -- %s", failure, msg) - s.Start() - - case coder.BuildLogTypeDone: - if s != nil { - s.Stop() - fmt.Print("\n") - } - - return nil - default: - return xerrors.Errorf("unknown buildlog type: %s", l.BuildLog.Type) - } - } - return nil -} - -func watchBuildLogCommand() *cobra.Command { - var user string - cmd := &cobra.Command{ - Use: "watch-build [workspace_name]", - Example: "coder workspaces watch-build front-end-workspace", - Short: "trail the build log of a Coder workspace", - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspace, err := findWorkspace(ctx, client, args[0], user) - if err != nil { - return err - } - - if err = trailBuildLogs(ctx, client, workspace.ID); err != nil { - return err - } - return nil - }, - } - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - return cmd -} diff --git a/internal/cmd/resourcemanager.go b/internal/cmd/resourcemanager.go deleted file mode 100644 index 5be08d24..00000000 --- a/internal/cmd/resourcemanager.go +++ /dev/null @@ -1,433 +0,0 @@ -package cmd - -import ( - "fmt" - "io" - "sort" - "strings" - "text/tabwriter" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" -) - -func resourceCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "resources", - Short: "manage Coder resources with platform-level context (users, organizations, workspaces)", - Hidden: true, - } - cmd.AddCommand(resourceTop()) - return cmd -} - -type resourceTopOptions struct { - group string - user string - org string - sortBy string - provider string - showEmptyGroups bool -} - -func resourceTop() *cobra.Command { - var options resourceTopOptions - - cmd := &cobra.Command{ - Use: "top", - Short: "resource viewer with Coder platform annotations", - RunE: runResourceTop(&options), - Args: xcobra.ExactArgs(0), - Example: `coder resources top --group org -coder resources top --group org --verbose --org DevOps -coder resources top --group user --verbose --user name@example.com -coder resources top --group provider --verbose --provider myprovider -coder resources top --sort-by memory --show-empty`, - } - cmd.Flags().StringVar(&options.group, "group", "user", "the grouping parameter (user|org|provider)") - cmd.Flags().StringVar(&options.user, "user", "", "filter by a user email") - cmd.Flags().StringVar(&options.org, "org", "", "filter by the name of an organization") - cmd.Flags().StringVar(&options.provider, "provider", "", "filter by the name of a workspace provider") - cmd.Flags().StringVar(&options.sortBy, "sort-by", "cpu", "field to sort aggregate groups and workspaces by (cpu|memory)") - cmd.Flags().BoolVar(&options.showEmptyGroups, "show-empty", false, "show groups with zero active workspaces") - - return cmd -} - -func runResourceTop(options *resourceTopOptions) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - // NOTE: it's not worth parrallelizing these calls yet given that this specific endpoint - // takes about 20x times longer than the other two - allWorkspaces, err := client.Workspaces(ctx) - if err != nil { - return xerrors.Errorf("get workspaces %w", err) - } - // only include workspaces whose last status was "ON" - workspaces := make([]coder.Workspace, 0) - for _, e := range allWorkspaces { - if e.LatestStat.ContainerStatus == coder.WorkspaceOn { - workspaces = append(workspaces, e) - } - } - - users, err := client.Users(ctx) - if err != nil { - return xerrors.Errorf("get users: %w", err) - } - images, err := coderutil.MakeImageMap(ctx, client, workspaces) - if err != nil { - return xerrors.Errorf("get images: %w", err) - } - - orgs, err := client.Organizations(ctx) - if err != nil { - return xerrors.Errorf("get organizations: %w", err) - } - - providers, err := client.WorkspaceProviders(ctx) - if err != nil { - return xerrors.Errorf("get workspace providers: %w", err) - } - data := entities{ - providers: providers.Kubernetes, - users: users, - orgs: orgs, - workspaces: workspaces, - images: images, - } - return presentEntites(cmd.OutOrStdout(), data, *options) - } -} - -func presentEntites(w io.Writer, data entities, options resourceTopOptions) error { - var ( - groups []groupable - labeler workspaceLabeler - ) - switch options.group { - case "user": - groups, labeler = aggregateByUser(data, options) - case "org": - groups, labeler = aggregateByOrg(data, options) - case "provider": - groups, labeler = aggregateByProvider(data, options) - default: - return xerrors.Errorf("unknown --group %q", options.group) - } - - return printResourceTop(w, groups, labeler, options.showEmptyGroups, options.sortBy) -} - -type entities struct { - providers []coder.KubernetesProvider - users []coder.User - orgs []coder.Organization - workspaces []coder.Workspace - images map[string]*coder.Image -} - -func aggregateByUser(data entities, options resourceTopOptions) ([]groupable, workspaceLabeler) { - var groups []groupable - providerIDMap := providerIDs(data.providers) - orgIDMap := make(map[string]coder.Organization) - for _, o := range data.orgs { - orgIDMap[o.ID] = o - } - userWorkspaces := make(map[string][]coder.Workspace, len(data.users)) - for _, e := range data.workspaces { - if options.org != "" && orgIDMap[e.OrganizationID].Name != options.org { - continue - } - userWorkspaces[e.UserID] = append(userWorkspaces[e.UserID], e) - } - for _, u := range data.users { - if options.user != "" && u.Email != options.user { - continue - } - groups = append(groups, userGrouping{user: u, userWorkspaces: userWorkspaces[u.ID]}) - } - return groups, labelAll(imgLabeler(data.images), providerLabeler(providerIDMap), orgLabeler(orgIDMap)) -} - -func userIDs(users []coder.User) map[string]coder.User { - userIDMap := make(map[string]coder.User) - for _, u := range users { - userIDMap[u.ID] = u - } - return userIDMap -} - -func aggregateByOrg(data entities, options resourceTopOptions) ([]groupable, workspaceLabeler) { - var groups []groupable - providerIDMap := providerIDs(data.providers) - orgWorkspaces := make(map[string][]coder.Workspace, len(data.orgs)) - userIDMap := userIDs(data.users) - for _, e := range data.workspaces { - if options.user != "" && userIDMap[e.UserID].Email != options.user { - continue - } - orgWorkspaces[e.OrganizationID] = append(orgWorkspaces[e.OrganizationID], e) - } - for _, o := range data.orgs { - if options.org != "" && o.Name != options.org { - continue - } - groups = append(groups, orgGrouping{org: o, orgWorkspaces: orgWorkspaces[o.ID]}) - } - return groups, labelAll(imgLabeler(data.images), userLabeler(userIDMap), providerLabeler(providerIDMap)) -} - -func providerIDs(providers []coder.KubernetesProvider) map[string]coder.KubernetesProvider { - providerIDMap := make(map[string]coder.KubernetesProvider) - for _, p := range providers { - providerIDMap[p.ID] = p - } - return providerIDMap -} - -func aggregateByProvider(data entities, options resourceTopOptions) ([]groupable, workspaceLabeler) { - var groups []groupable - providerIDMap := providerIDs(data.providers) - userIDMap := userIDs(data.users) - providerWorkspaces := make(map[string][]coder.Workspace, len(data.providers)) - for _, e := range data.workspaces { - if options.provider != "" && providerIDMap[e.ResourcePoolID].Name != options.provider { - continue - } - providerWorkspaces[e.ResourcePoolID] = append(providerWorkspaces[e.ResourcePoolID], e) - } - for _, p := range data.providers { - if options.provider != "" && p.Name != options.provider { - continue - } - groups = append(groups, providerGrouping{provider: p, providerWorkspaces: providerWorkspaces[p.ID]}) - } - return groups, labelAll(imgLabeler(data.images), userLabeler(userIDMap)) // TODO: consider adding an org label here -} - -// groupable specifies a structure capable of being an aggregation group of workspaces (user, org, all). -type groupable interface { - header() string - workspaces() []coder.Workspace -} - -type userGrouping struct { - user coder.User - userWorkspaces []coder.Workspace -} - -func (u userGrouping) workspaces() []coder.Workspace { - return u.userWorkspaces -} - -func (u userGrouping) header() string { - return fmt.Sprintf("%s\t(%s)", truncate(u.user.Name, 20, "..."), u.user.Email) -} - -type orgGrouping struct { - org coder.Organization - orgWorkspaces []coder.Workspace -} - -func (o orgGrouping) workspaces() []coder.Workspace { - return o.orgWorkspaces -} - -func (o orgGrouping) header() string { - plural := "s" - if len(o.org.Members) == 1 { - plural = "" - } - return fmt.Sprintf("%s\t(%v member%s)", truncate(o.org.Name, 20, "..."), len(o.org.Members), plural) -} - -type providerGrouping struct { - provider coder.KubernetesProvider - providerWorkspaces []coder.Workspace -} - -func (p providerGrouping) workspaces() []coder.Workspace { - return p.providerWorkspaces -} - -func (p providerGrouping) header() string { - return fmt.Sprintf("%s\t", truncate(p.provider.Name, 20, "...")) -} - -func printResourceTop(writer io.Writer, groups []groupable, labeler workspaceLabeler, showEmptyGroups bool, sortBy string) error { - tabwriter := tabwriter.NewWriter(writer, 0, 0, 4, ' ', 0) - defer func() { _ = tabwriter.Flush() }() - - var userResources []aggregatedResources - for _, group := range groups { - if !showEmptyGroups && len(group.workspaces()) < 1 { - continue - } - userResources = append(userResources, aggregatedResources{ - groupable: group, resources: aggregateWorkspaceResources(group.workspaces()), - }) - } - - err := sortAggregatedResources(userResources, sortBy) - if err != nil { - return err - } - - for _, u := range userResources { - _, _ = fmt.Fprintf(tabwriter, "%s\t%s", u.header(), u.resources) - if verbose { - if len(u.workspaces()) > 0 { - _, _ = fmt.Fprintf(tabwriter, "\f") - } - for _, workspace := range u.workspaces() { - _, _ = fmt.Fprintf(tabwriter, "\t") - _, _ = fmt.Fprintln(tabwriter, fmtWorkspaceResources(workspace, labeler)) - } - } - _, _ = fmt.Fprint(tabwriter, "\n") - } - if len(userResources) == 0 { - clog.LogInfo( - "no groups for the given filters exist with active workspaces", - clog.Tipf("run \"--show-empty\" to see groups with no resources."), - ) - } - return nil -} - -func sortAggregatedResources(resources []aggregatedResources, sortBy string) error { - const cpu = "cpu" - const memory = "memory" - switch sortBy { - case cpu: - sort.Slice(resources, func(i, j int) bool { - return resources[i].cpuAllocation > resources[j].cpuAllocation - }) - case memory: - sort.Slice(resources, func(i, j int) bool { - return resources[i].memAllocation > resources[j].memAllocation - }) - default: - return xerrors.Errorf("unknown --sort-by value of \"%s\"", sortBy) - } - for _, group := range resources { - workspaces := group.workspaces() - switch sortBy { - case cpu: - sort.Slice(workspaces, func(i, j int) bool { return workspaces[i].CPUCores > workspaces[j].CPUCores }) - case memory: - sort.Slice(workspaces, func(i, j int) bool { return workspaces[i].MemoryGB > workspaces[j].MemoryGB }) - default: - return xerrors.Errorf("unknown --sort-by value of \"%s\"", sortBy) - } - } - return nil -} - -type aggregatedResources struct { - groupable - resources -} - -func resourcesFromWorkspace(workspace coder.Workspace) resources { - return resources{ - cpuAllocation: workspace.CPUCores, - cpuUtilization: workspace.LatestStat.CPUUsage, - memAllocation: workspace.MemoryGB, - memUtilization: workspace.LatestStat.MemoryUsage, - } -} - -func fmtWorkspaceResources(workspace coder.Workspace, labeler workspaceLabeler) string { - return fmt.Sprintf("%s\t%s\t%s", truncate(workspace.Name, 20, "..."), resourcesFromWorkspace(workspace), labeler.label(workspace)) -} - -type workspaceLabeler interface { - label(coder.Workspace) string -} - -func labelAll(labels ...workspaceLabeler) workspaceLabeler { return multiLabeler(labels) } - -type multiLabeler []workspaceLabeler - -func (m multiLabeler) label(e coder.Workspace) string { - var str strings.Builder - for i, labeler := range m { - if i != 0 { - str.WriteString("\t") - } - str.WriteString(labeler.label(e)) - } - return str.String() -} - -type orgLabeler map[string]coder.Organization - -func (o orgLabeler) label(e coder.Workspace) string { - return fmt.Sprintf("[org: %s]", o[e.OrganizationID].Name) -} - -type imgLabeler map[string]*coder.Image - -func (i imgLabeler) label(e coder.Workspace) string { - return fmt.Sprintf("[img: %s:%s]", i[e.ImageID].Repository, e.ImageTag) -} - -type userLabeler map[string]coder.User - -func (u userLabeler) label(e coder.Workspace) string { - return fmt.Sprintf("[user: %s]", u[e.UserID].Email) -} - -type providerLabeler map[string]coder.KubernetesProvider - -func (p providerLabeler) label(e coder.Workspace) string { - return fmt.Sprintf("[provider: %s]", p[e.ResourcePoolID].Name) -} - -func aggregateWorkspaceResources(workspaces []coder.Workspace) resources { - var aggregate resources - for _, e := range workspaces { - aggregate.cpuAllocation += e.CPUCores - aggregate.cpuUtilization += e.LatestStat.CPUUsage - aggregate.memAllocation += e.MemoryGB - aggregate.memUtilization += e.LatestStat.MemoryUsage - } - return aggregate -} - -type resources struct { - cpuAllocation float32 - memAllocation float32 - - // TODO: consider using these - cpuUtilization float32 - memUtilization float32 -} - -func (a resources) String() string { - return fmt.Sprintf( - "[cpu: %.1f]\t[mem: %.1f GB]", - a.cpuAllocation, a.memAllocation, - ) -} - -//nolint:unparam -// truncate the given string and replace the removed chars with some replacement (ex: "..."). -func truncate(str string, max int, replace string) string { - if len(str) <= max { - return str - } - return str[:max+1] + replace -} diff --git a/internal/cmd/resourcemanager_test.go b/internal/cmd/resourcemanager_test.go deleted file mode 100644 index ffe3dbb9..00000000 --- a/internal/cmd/resourcemanager_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package cmd - -import ( - "bytes" - "flag" - "fmt" - "io/ioutil" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -var write = flag.Bool("write", false, "write to the golden files") - -func Test_resourceManager(t *testing.T) { - // TODO: cleanup - verbose = true - - const goldenFile = "resourcemanager_test.golden" - var buff bytes.Buffer - data := mockResourceTopEntities() - tests := []struct { - header string - data entities - options resourceTopOptions - }{ - { - header: "By User", - data: data, - options: resourceTopOptions{ - group: "user", - sortBy: "cpu", - }, - }, - { - header: "By Org", - data: data, - options: resourceTopOptions{ - group: "org", - sortBy: "cpu", - }, - }, - { - header: "By Provider", - data: data, - options: resourceTopOptions{ - group: "provider", - sortBy: "cpu", - }, - }, - { - header: "Sort By Memory", - data: data, - options: resourceTopOptions{ - group: "user", - sortBy: "memory", - }, - }, - } - - for _, tcase := range tests { - buff.WriteString(fmt.Sprintf("=== TEST: %s\n", tcase.header)) - err := presentEntites(&buff, tcase.data, tcase.options) - assert.Success(t, "present entities", err) - } - - assertGolden(t, goldenFile, buff.Bytes()) -} - -func assertGolden(t *testing.T, path string, output []byte) { - if *write { - err := ioutil.WriteFile(path, output, 0777) - assert.Success(t, "write file", err) - return - } - goldenContent, err := ioutil.ReadFile(path) - assert.Success(t, "read golden file", err) - assert.Equal(t, "golden content matches", string(goldenContent), string(output)) -} - -func mockResourceTopEntities() entities { - orgIDs := [...]string{randString(10), randString(10), randString(10)} - imageIDs := [...]string{randString(10), randString(10), randString(10)} - providerIDs := [...]string{randString(10), randString(10), randString(10)} - userIDs := [...]string{randString(10), randString(10), randString(10)} - workspaceIDs := [...]string{randString(10), randString(10), randString(10), randString(10)} - - return entities{ - providers: []coder.KubernetesProvider{ - { - ID: providerIDs[0], - Name: "mars", - }, - { - ID: providerIDs[1], - Name: "underground", - }, - }, - users: []coder.User{ - { - ID: userIDs[0], - Name: "Random", - Email: "random@coder.com", - }, - { - ID: userIDs[1], - Name: "Second Random", - Email: "second-random@coder.com", - }, - }, - orgs: []coder.Organization{ - { - ID: orgIDs[0], - Name: "SpecialOrg", - - //! these should probably be fixed, but for now they are just for the count - Members: []coder.OrganizationUser{{}, {}}, - }, - { - ID: orgIDs[1], - Name: "NotSoSpecialOrg", - - //! these should probably be fixed, but for now they are just for the count - Members: []coder.OrganizationUser{{}, {}}, - }, - }, - workspaces: []coder.Workspace{ - { - ID: workspaceIDs[0], - ResourcePoolID: providerIDs[0], - ImageID: imageIDs[0], - OrganizationID: orgIDs[0], - UserID: userIDs[0], - Name: "dev-workspace", - ImageTag: "20.04", - CPUCores: 12.2, - MemoryGB: 64.4, - LatestStat: coder.WorkspaceStat{ - ContainerStatus: coder.WorkspaceOn, - }, - }, - { - ID: workspaceIDs[1], - ResourcePoolID: providerIDs[1], - ImageID: imageIDs[1], - OrganizationID: orgIDs[1], - UserID: userIDs[1], - Name: "another-workspace", - ImageTag: "10.2", - CPUCores: 4, - MemoryGB: 16, - LatestStat: coder.WorkspaceStat{ - ContainerStatus: coder.WorkspaceOn, - }, - }, - { - ID: workspaceIDs[2], - ResourcePoolID: providerIDs[1], - ImageID: imageIDs[1], - OrganizationID: orgIDs[1], - UserID: userIDs[1], - Name: "yet-another-workspace", - ImageTag: "10.2", - CPUCores: 100, - MemoryGB: 2, - LatestStat: coder.WorkspaceStat{ - ContainerStatus: coder.WorkspaceOn, - }, - }, - }, - images: map[string]*coder.Image{ - imageIDs[0]: { - Repository: "ubuntu", - OrganizationID: orgIDs[0], - }, - imageIDs[1]: { - Repository: "archlinux", - OrganizationID: orgIDs[0], - }, - }, - } -} diff --git a/internal/cmd/resourcemanager_test.golden b/internal/cmd/resourcemanager_test.golden deleted file mode 100755 index 0bed13ee..00000000 --- a/internal/cmd/resourcemanager_test.golden +++ /dev/null @@ -1,32 +0,0 @@ -=== TEST: By User -Second Random (second-random@coder.com) [cpu: 104.0] [mem: 18.0 GB] - yet-another-workspace... [cpu: 100.0] [mem: 2.0 GB] [img: archlinux:10.2] [provider: underground] [org: NotSoSpecialOrg] - another-workspace [cpu: 4.0] [mem: 16.0 GB] [img: archlinux:10.2] [provider: underground] [org: NotSoSpecialOrg] - -Random (random@coder.com) [cpu: 12.2] [mem: 64.4 GB] - dev-workspace [cpu: 12.2] [mem: 64.4 GB] [img: ubuntu:20.04] [provider: mars] [org: SpecialOrg] - -=== TEST: By Org -NotSoSpecialOrg (2 members) [cpu: 104.0] [mem: 18.0 GB] - yet-another-workspace... [cpu: 100.0] [mem: 2.0 GB] [img: archlinux:10.2] [user: second-random@coder.com] [provider: underground] - another-workspace [cpu: 4.0] [mem: 16.0 GB] [img: archlinux:10.2] [user: second-random@coder.com] [provider: underground] - -SpecialOrg (2 members) [cpu: 12.2] [mem: 64.4 GB] - dev-workspace [cpu: 12.2] [mem: 64.4 GB] [img: ubuntu:20.04] [user: random@coder.com] [provider: mars] - -=== TEST: By Provider -underground [cpu: 104.0] [mem: 18.0 GB] - yet-another-workspace... [cpu: 100.0] [mem: 2.0 GB] [img: archlinux:10.2] [user: second-random@coder.com] - another-workspace [cpu: 4.0] [mem: 16.0 GB] [img: archlinux:10.2] [user: second-random@coder.com] - -mars [cpu: 12.2] [mem: 64.4 GB] - dev-workspace [cpu: 12.2] [mem: 64.4 GB] [img: ubuntu:20.04] [user: random@coder.com] - -=== TEST: Sort By Memory -Random (random@coder.com) [cpu: 12.2] [mem: 64.4 GB] - dev-workspace [cpu: 12.2] [mem: 64.4 GB] [img: ubuntu:20.04] [provider: mars] [org: SpecialOrg] - -Second Random (second-random@coder.com) [cpu: 104.0] [mem: 18.0 GB] - another-workspace [cpu: 4.0] [mem: 16.0 GB] [img: archlinux:10.2] [provider: underground] [org: NotSoSpecialOrg] - yet-another-workspace... [cpu: 100.0] [mem: 2.0 GB] [img: archlinux:10.2] [provider: underground] [org: NotSoSpecialOrg] - diff --git a/internal/cmd/satellites.go b/internal/cmd/satellites.go deleted file mode 100644 index 982451f7..00000000 --- a/internal/cmd/satellites.go +++ /dev/null @@ -1,222 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - "cdr.dev/coder-cli/internal/x/xcobra" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -const ( - satelliteKeyPath = "/api/private/satellites/key" -) - -type satelliteKeyResponse struct { - Key string `json:"key"` - Fingerprint string `json:"fingerprint"` -} - -func satellitesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "satellites", - Short: "Interact with Coder satellite deployments", - Long: "Perform operations on the Coder satellites for the platform.", - } - - cmd.AddCommand( - createSatelliteCmd(), - listSatellitesCmd(), - deleteSatelliteCmd(), - ) - return cmd -} - -func createSatelliteCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "create [name] [satellite_access_url]", - Args: xcobra.ExactArgs(2), - Short: "create a new satellite.", - Long: "Create a new Coder satellite.", - Example: `# create a new satellite - -coder satellites create eu-west https://eu-west.coder.com`, - RunE: func(cmd *cobra.Command, args []string) error { - var ( - ctx = cmd.Context() - name = args[0] - accessURL = args[1] - ) - - client, err := newClient(ctx, true) - if err != nil { - return xerrors.Errorf("making coder client: %w", err) - } - - sURL, err := url.Parse(accessURL) - if err != nil { - return xerrors.Errorf("parsing satellite access url: %w", err) - } - sURL.Path = satelliteKeyPath - - // Create the http request. - req, err := http.NewRequestWithContext(ctx, http.MethodGet, sURL.String(), nil) - if err != nil { - return xerrors.Errorf("create satellite request: %w", err) - } - res, err := http.DefaultClient.Do(req) - if err != nil { - return xerrors.Errorf("doing satellite request: %w", err) - } - defer func() { _ = res.Body.Close() }() - - if res.StatusCode > 299 { - return fmt.Errorf("unexpected status code %d: %+v", res.StatusCode, res) - } - - var keyRes satelliteKeyResponse - if err := json.NewDecoder(res.Body).Decode(&keyRes); err != nil { - return xerrors.Errorf("decode response body: %w", err) - } - - if keyRes.Key == "" { - return xerrors.New("key field empty in response") - } - if keyRes.Fingerprint == "" { - return xerrors.New("fingerprint field empty in response") - } - - fmt.Printf(`The following satellite will be created: -Name: %s - -Public Key: -%s - -Fingerprint: -%s - -Do you wish to continue? (y/n) -`, name, keyRes.Key, keyRes.Fingerprint) - err = getConfirmation() - if err != nil { - return err - } - - _, err = client.CreateSatellite(ctx, coder.CreateSatelliteReq{ - Name: name, - PublicKey: keyRes.Key, - }) - if err != nil { - return xerrors.Errorf("making create satellite request: %w", err) - } - - clog.LogSuccess(fmt.Sprintf("satellite %s successfully created", name)) - - return nil - }, - } - - return cmd -} - -func getConfirmation() error { - var response string - - _, err := fmt.Scanln(&response) - if err != nil { - return xerrors.Errorf("scan line: %w", err) - } - - response = strings.ToLower(strings.TrimSpace(response)) - if response != "y" && response != "yes" { - return xerrors.New("request canceled") - } - - return nil -} - -func listSatellitesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "ls", - Short: "list satellites.", - Long: "List all Coder workspace satellites.", - Example: `# list satellites -coder satellites ls`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - client, err := newClient(ctx, true) - if err != nil { - return xerrors.Errorf("making coder client: %w", err) - } - - sats, err := client.Satellites(ctx) - if err != nil { - return xerrors.Errorf("get satellites request: %w", err) - } - - if len(sats) == 0 { - return xerrors.Errorf("no satellites found") - } - - err = tablewriter.WriteTable(cmd.OutOrStdout(), len(sats), func(i int) interface{} { - return sats[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - - return nil - }, - } - return cmd -} - -func deleteSatelliteCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "rm [satellite_name]", - Args: xcobra.ExactArgs(1), - Short: "remove a satellite.", - Long: "Remove an existing Coder satellite by name.", - Example: `# remove an existing satellite by name -coder satellites rm my-satellite`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - name := args[0] - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - sats, err := client.Satellites(ctx) - if err != nil { - return xerrors.Errorf("get satellites request: %w", err) - } - - for _, sat := range sats { - if sat.Name == name { - err = client.DeleteSatelliteByID(ctx, sat.ID) - if err != nil { - return xerrors.Errorf("delete satellites request: %w", err) - } - clog.LogSuccess(fmt.Sprintf("satellite %s successfully deleted", name)) - - return nil - } - } - - return xerrors.Errorf("no satellite found by name '%s'", name) - }, - } - return cmd -} diff --git a/internal/cmd/ssh.go b/internal/cmd/ssh.go deleted file mode 100644 index cec588a6..00000000 --- a/internal/cmd/ssh.go +++ /dev/null @@ -1,117 +0,0 @@ -package cmd - -import ( - "fmt" - "net/url" - "os" - "os/exec" - "os/user" - "path/filepath" - - "github.com/spf13/cobra" - "golang.org/x/term" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" -) - -var ( - showInteractiveOutput = term.IsTerminal(int(os.Stdout.Fd())) -) - -func sshCmd() *cobra.Command { - cmd := cobra.Command{ - Use: "ssh [workspace_name] []", - Short: "Enter a shell of execute a command over SSH into a Coder workspace", - Args: shValidArgs, - Example: `coder ssh my-dev -coder ssh my-dev pwd`, - Aliases: []string{"sh"}, - DisableFlagParsing: true, - DisableFlagsInUseLine: true, - RunE: shell, - } - return &cmd -} - -func shell(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - me, err := client.Me(ctx) - if err != nil { - return err - } - workspace, err := findWorkspace(ctx, client, args[0], coder.Me) - if err != nil { - return err - } - if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn { - return clog.Error("workspace not available", - fmt.Sprintf("current status: \"%s\"", workspace.LatestStat.ContainerStatus), - clog.BlankLine, - clog.Tipf("use \"coder workspaces rebuild %s\" to rebuild this workspace", workspace.Name), - ) - } - wp, err := client.WorkspaceProviderByID(ctx, workspace.ResourcePoolID) - if err != nil { - return err - } - u, err := url.Parse(wp.EnvproxyAccessURL) - if err != nil { - return err - } - - usr, err := user.Current() - if err != nil { - return xerrors.Errorf("get user home directory: %w", err) - } - privateKeyFilepath := filepath.Join(usr.HomeDir, ".ssh", "coder_enterprise") - - err = writeSSHKey(ctx, client, privateKeyFilepath) - if err != nil { - return err - } - ssh := exec.CommandContext(ctx, - "ssh", "-i"+privateKeyFilepath, - fmt.Sprintf("%s-%s@%s", me.Username, workspace.Name, u.Hostname()), - ) - if len(args) > 1 { - ssh.Args = append(ssh.Args, args[1:]...) - } - ssh.Stderr = os.Stderr - ssh.Stdout = os.Stdout - ssh.Stdin = os.Stdin - err = ssh.Run() - var exitErr *exec.ExitError - if xerrors.As(err, &exitErr) { - os.Exit(exitErr.ExitCode()) - return xerrors.New("unreachable") - } - return err -} - -// special handling for the common case of "coder sh" input without a positional argument. -func shValidArgs(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - err := cobra.MinimumNArgs(1)(cmd, args) - if err != nil { - client, err := newClient(ctx, true) - if err != nil { - return clog.Error("missing [workspace_name] argument") - } - _, haystack, err := searchForWorkspace(ctx, client, "", coder.Me) - if err != nil { - return clog.Error("missing [workspace_name] argument", - fmt.Sprintf("specify one of %q", haystack), - clog.BlankLine, - clog.Tipf("run \"coder workspaces ls\" to view your workspaces"), - ) - } - return clog.Error("missing [workspace_name] argument") - } - return nil -} diff --git a/internal/cmd/sync.go b/internal/cmd/sync.go deleted file mode 100644 index eadb3853..00000000 --- a/internal/cmd/sync.go +++ /dev/null @@ -1,121 +0,0 @@ -package cmd - -import ( - "bytes" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/sync" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" -) - -func syncCmd() *cobra.Command { - var init bool - cmd := &cobra.Command{ - Use: "sync [local directory] [:]", - Short: "Establish a one way directory sync to a Coder workspace", - Args: xcobra.ExactArgs(2), - RunE: makeRunSync(&init), - } - cmd.Flags().BoolVar(&init, "init", false, "do initial transfer and exit") - return cmd -} - -// rsyncVersion returns local rsync protocol version as a string. -func rsyncVersion() string { - cmd := exec.Command("rsync", "--version") - out, err := cmd.CombinedOutput() - if err != nil { - log.Fatal(err) - } - - firstLine, err := bytes.NewBuffer(out).ReadString('\n') - if err != nil { - log.Fatal(err) - } - versionString := strings.Split(firstLine, "protocol version ") - - return versionString[1] -} - -func makeRunSync(init *bool) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - var ( - ctx = cmd.Context() - local = args[0] - remote = args[1] - ) - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - remoteTokens := strings.SplitN(remote, ":", 2) - if len(remoteTokens) != 2 { - return xerrors.New("remote malformatted") - } - var ( - workspaceName = remoteTokens[0] - remoteDir = remoteTokens[1] - ) - - workspace, err := findWorkspace(ctx, client, workspaceName, coder.Me) - if err != nil { - return err - } - - info, err := os.Stat(local) - if err != nil { - return err - } - if info.Mode().IsRegular() { - return sync.SingleFile(ctx, local, remoteDir, workspace, client) - } - if !info.IsDir() { - return xerrors.Errorf("local path must lead to a regular file or directory: %w", err) - } - - absLocal, err := filepath.Abs(local) - if err != nil { - return xerrors.Errorf("make abs path out of %s, %s: %w", local, absLocal, err) - } - - s := sync.Sync{ - Init: *init, - Workspace: *workspace, - RemoteDir: remoteDir, - LocalDir: absLocal, - Client: client, - OutW: cmd.OutOrStdout(), - ErrW: cmd.ErrOrStderr(), - InputReader: cmd.InOrStdin(), - IsInteractiveOutput: showInteractiveOutput, - } - - localVersion := rsyncVersion() - remoteVersion, rsyncErr := s.Version() - - if rsyncErr != nil { - clog.LogInfo("unable to determine remote rsync version: proceeding cautiously") - } else if localVersion != remoteVersion { - return xerrors.Errorf("rsync protocol mismatch: local = %s, remote = %s", localVersion, remoteVersion) - } - - for err == nil || err == sync.ErrRestartSync { - err = s.Run() - } - if err != nil { - return err - } - return nil - } -} diff --git a/internal/cmd/tags.go b/internal/cmd/tags.go deleted file mode 100644 index 24a7affa..00000000 --- a/internal/cmd/tags.go +++ /dev/null @@ -1,173 +0,0 @@ -package cmd - -import ( - "encoding/json" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func tagsCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "tags", - Hidden: true, - Short: "operate on Coder image tags", - } - - cmd.AddCommand( - tagsLsCmd(), - tagsCreateCmd(), - tagsRmCmd(), - ) - return cmd -} - -func tagsCreateCmd() *cobra.Command { - var ( - orgName string - imageName string - defaultTag bool - ) - cmd := &cobra.Command{ - Use: "create [tag]", - Short: "add an image tag", - Long: "allow users to create workspaces with this image tag", - Example: `coder tags create latest --image ubuntu --org default`, - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - img, err := findImg(ctx, client, findImgConf{ - orgName: orgName, - imgName: imageName, - email: coder.Me, - }) - if err != nil { - return xerrors.Errorf("find image: %w", err) - } - - _, err = client.CreateImageTag(ctx, img.ID, coder.CreateImageTagReq{ - Tag: args[0], - Default: defaultTag, - }) - if err != nil { - return xerrors.Errorf("create image tag: %w", err) - } - clog.LogSuccess("created new tag") - - return nil - }, - } - - cmd.Flags().StringVarP(&imageName, "image", "i", "", "image name") - cmd.Flags().StringVarP(&orgName, "org", "o", "", "organization name") - cmd.Flags().BoolVar(&defaultTag, "default", false, "make this tag the default for its image") - _ = cmd.MarkFlagRequired("org") - _ = cmd.MarkFlagRequired("image") - return cmd -} - -func tagsLsCmd() *cobra.Command { - var ( - orgName string - imageName string - outputFmt string - ) - cmd := &cobra.Command{ - Use: "ls", - Example: `coder tags ls --image ubuntu --org default --output json`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - img, err := findImg(ctx, client, findImgConf{ - email: coder.Me, - orgName: orgName, - imgName: imageName, - }) - if err != nil { - return err - } - - tags, err := client.ImageTags(ctx, img.ID) - if err != nil { - return err - } - - switch outputFmt { - case humanOutput: - err = tablewriter.WriteTable(cmd.OutOrStdout(), len(tags), func(i int) interface{} { return tags[i] }) - if err != nil { - return err - } - case jsonOutput: - err := json.NewEncoder(cmd.OutOrStdout()).Encode(tags) - if err != nil { - return err - } - default: - return clog.Error("unknown --output value") - } - - return nil - }, - } - cmd.Flags().StringVar(&orgName, "org", "", "organization by name") - cmd.Flags().StringVarP(&imageName, "image", "i", "", "image by name") - cmd.Flags().StringVar(&outputFmt, "output", humanOutput, "output format (human|json)") - _ = cmd.MarkFlagRequired("image") - _ = cmd.MarkFlagRequired("org") - return cmd -} - -func tagsRmCmd() *cobra.Command { - var ( - imageName string - orgName string - ) - cmd := &cobra.Command{ - Use: "rm [tag]", - Short: "remove an image tag", - Example: `coder tags rm latest --image ubuntu --org default`, - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - img, err := findImg(ctx, client, findImgConf{ - email: coder.Me, - imgName: imageName, - orgName: orgName, - }) - if err != nil { - return err - } - - if err = client.DeleteImageTag(ctx, img.ID, args[0]); err != nil { - return err - } - clog.LogSuccess("removed tag") - - return nil - }, - } - cmd.Flags().StringVarP(&orgName, "org", "o", "", "organization by name") - cmd.Flags().StringVarP(&imageName, "image", "i", "", "image by name") - _ = cmd.MarkFlagRequired("image") - _ = cmd.MarkFlagRequired("org") - return cmd -} diff --git a/internal/cmd/tags_test.go b/internal/cmd/tags_test.go deleted file mode 100644 index c04e7098..00000000 --- a/internal/cmd/tags_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "context" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/coder-sdk" -) - -func Test_tags(t *testing.T) { - t.Skip("TODO: wait for dedicated test API server / DB so we can create an org") - ctx := context.Background() - - skipIfNoAuth(t) - - res := execute(t, nil, "tags", "ls") - res.error(t) - - ensureImageImported(ctx, t, testCoderClient, "ubuntu", "latest") - - res = execute(t, nil, "tags", "ls", "--image=ubuntu", "--org=default") - res.success(t) - - var tags []coder.ImageTag - res = execute(t, nil, "tags", "ls", "--image=ubuntu", "--org=default", "--output=json") - res.success(t) - res.stdoutUnmarshals(t, &tags) - assert.True(t, "> 0 tags", len(tags) > 0) -} diff --git a/internal/cmd/tokens.go b/internal/cmd/tokens.go deleted file mode 100644 index a014f546..00000000 --- a/internal/cmd/tokens.go +++ /dev/null @@ -1,136 +0,0 @@ -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func tokensCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "tokens", - Short: "manage Coder API tokens for the active user", - Long: "Create and manage API Tokens for authenticating the CLI.\n" + - "Statically authenticate using the token value with the " + "`" + "CODER_TOKEN" + "`" + " and " + "`" + "CODER_URL" + "`" + " workspace variables.", - } - cmd.AddCommand( - lsTokensCmd(), - createTokensCmd(), - rmTokenCmd(), - regenTokenCmd(), - ) - return cmd -} - -func lsTokensCmd() *cobra.Command { - var outputFmt string - - cmd := &cobra.Command{ - Use: "ls", - Short: "show the user's active API tokens", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - tokens, err := client.APITokens(ctx, coder.Me) - if err != nil { - return err - } - - switch outputFmt { - case humanOutput: - err := tablewriter.WriteTable(cmd.OutOrStdout(), len(tokens), func(i int) interface{} { - return tokens[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - case jsonOutput: - err := json.NewEncoder(cmd.OutOrStdout()).Encode(tokens) - if err != nil { - return xerrors.Errorf("write tokens as JSON: %w", err) - } - default: - return xerrors.Errorf("unknown --output value %q", outputFmt) - } - - return nil - }, - } - - cmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human | json") - - return cmd -} - -func createTokensCmd() *cobra.Command { - return &cobra.Command{ - Use: "create [token_name]", - Short: "create generates a new API token and prints it to stdout", - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - token, err := client.CreateAPIToken(ctx, coder.Me, coder.CreateAPITokenReq{ - Name: args[0], - }) - if err != nil { - return err - } - fmt.Println(token) - return nil - }, - } -} - -func rmTokenCmd() *cobra.Command { - return &cobra.Command{ - Use: "rm [token_id]", - Short: "remove an API token by its unique ID", - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - if err = client.DeleteAPIToken(ctx, coder.Me, args[0]); err != nil { - return err - } - return nil - }, - } -} - -func regenTokenCmd() *cobra.Command { - return &cobra.Command{ - Use: "regen [token_id]", - Short: "regenerate an API token by its unique ID and print the new token to stdout", - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - token, err := client.RegenerateAPIToken(ctx, coder.Me, args[0]) - if err != nil { - return nil - } - fmt.Println(token) - return nil - }, - } -} diff --git a/internal/cmd/tunnel.go b/internal/cmd/tunnel.go deleted file mode 100644 index 203a9786..00000000 --- a/internal/cmd/tunnel.go +++ /dev/null @@ -1,297 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "io" - "net" - "net/url" - "os" - "strconv" - "time" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" - "github.com/fatih/color" - "github.com/pion/webrtc/v3" - "github.com/spf13/cobra" - "golang.org/x/crypto/ssh" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/wsnet" -) - -func tunnelCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "tunnel [workspace_name] [workspace_port] [localhost_port]", - Args: xcobra.ExactArgs(3), - Short: "proxies a port on the workspace to localhost", - Long: "proxies a port on the workspace to localhost", - Example: `# run a tcp tunnel from the workspace on port 3000 to localhost:3000 - -coder tunnel my-dev 3000 3000 -`, - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := context.Background() - log := slog.Make(sloghuman.Sink(os.Stderr)) - if os.Getenv("CODER_TUNNEL_DEBUG") != "" { - log = log.Leveled(slog.LevelDebug) - log.Info(ctx, "debug logging enabled") - } - - remotePort, err := strconv.ParseUint(args[1], 10, 16) - if err != nil { - return xerrors.Errorf("parse remote port: %w", err) - } - - var localPort uint64 - if args[2] != "stdio" { - localPort, err = strconv.ParseUint(args[2], 10, 16) - if err != nil { - return xerrors.Errorf("parse local port: %w", err) - } - } - - sdk, err := newClient(ctx, false) - if err != nil { - return xerrors.Errorf("getting coder client: %w", err) - } - baseURL := sdk.BaseURL() - - workspace, err := findWorkspace(ctx, sdk, args[0], coder.Me) - if err != nil { - return xerrors.Errorf("get workspaces: %w", err) - } - - if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn { - color.NoColor = false - notAvailableError := clog.Error("workspace not available", - fmt.Sprintf("current status: %q", workspace.LatestStat.ContainerStatus), - clog.BlankLine, - clog.Tipf("use \"coder workspaces rebuild %s\" to rebuild this workspace", workspace.Name), - ) - // If we're attempting to forward our remote SSH port, - // we want to communicate with the OpenSSH protocol so - // SSH clients can properly display output to our users. - if remotePort == 12213 { - rawKey, err := sdk.SSHKey(ctx) - if err != nil { - return xerrors.Errorf("get ssh key: %w", err) - } - err = discardSSHConnection(&stdioConn{}, rawKey.PrivateKey, notAvailableError.String()) - if err != nil { - return err - } - return nil - } - - return notAvailableError - } - - iceServers, err := sdk.ICEServers(ctx) - if err != nil { - return xerrors.Errorf("get ICE servers: %w", err) - } - log.Debug(ctx, "got ICE servers", slog.F("ice", iceServers)) - - c := &tunnneler{ - log: log, - brokerAddr: &baseURL, - token: sdk.Token(), - workspace: workspace, - iceServers: iceServers, - stdio: args[2] == "stdio", - localPort: uint16(localPort), - remotePort: uint16(remotePort), - } - - err = c.start(ctx) - if err != nil { - return xerrors.Errorf("running tunnel: %w", err) - } - - return nil - }, - } - - return cmd -} - -type tunnneler struct { - log slog.Logger - brokerAddr *url.URL - token string - workspace *coder.Workspace - iceServers []webrtc.ICEServer - remotePort uint16 - localPort uint16 - stdio bool -} - -func (c *tunnneler) start(ctx context.Context) error { - c.log.Debug(ctx, "Connecting to workspace...") - - dialLog := c.log.Named("wsnet") - wd, err := wsnet.DialWebsocket( - ctx, - wsnet.ConnectEndpoint(c.brokerAddr, c.workspace.ID, c.token), - &wsnet.DialOptions{ - Log: &dialLog, - TURNProxyAuthToken: c.token, - TURNRemoteProxyURL: c.brokerAddr, - TURNLocalProxyURL: c.brokerAddr, - ICEServers: c.iceServers, - }, - nil, - ) - if err != nil { - return xerrors.Errorf("creating workspace dialer: %w", err) - } - nc, err := wd.DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", c.remotePort)) - if err != nil { - return err - } - c.log.Debug(ctx, "Connected to workspace!") - - sdk, err := newClient(ctx, false) - if err != nil { - return xerrors.Errorf("getting coder client: %w", err) - } - - // regularly update the last connection at - go func() { - ticker := time.NewTicker(time.Minute) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - // silently ignore failures so we don't spam the console - _ = sdk.UpdateLastConnectionAt(ctx, c.workspace.ID) - } - } - }() - - // proxy via stdio - if c.stdio { - go func() { - _, _ = io.Copy(nc, os.Stdin) - }() - _, err = io.Copy(os.Stdout, nc) - if err != nil { - return xerrors.Errorf("copy: %w", err) - } - return nil - } - // This was used to test if the port was open, and proxy over stdio - // if the user specified that. - _ = nc.Close() - - // proxy via tcp listener - listener, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", c.localPort)) - if err != nil { - return xerrors.Errorf("listen: %w", err) - } - - for { - lc, err := listener.Accept() - if err != nil { - return xerrors.Errorf("accept: %w", err) - } - nc, err := wd.DialContext(ctx, "tcp", fmt.Sprintf("localhost:%d", c.remotePort)) - if err != nil { - return err - } - go func() { - defer func() { - _ = lc.Close() - }() - - go func() { - _, _ = io.Copy(lc, nc) - }() - _, _ = io.Copy(nc, lc) - }() - } -} - -// Used to treat stdio like a connection for proxying SSH. -type stdioConn struct{} - -func (s *stdioConn) Read(b []byte) (n int, err error) { - return os.Stdin.Read(b) -} - -func (s *stdioConn) Write(b []byte) (n int, err error) { - return os.Stdout.Write(b) -} - -func (s *stdioConn) Close() error { - return nil -} - -func (s *stdioConn) LocalAddr() net.Addr { - return nil -} - -func (s *stdioConn) RemoteAddr() net.Addr { - return nil -} - -func (s *stdioConn) SetDeadline(t time.Time) error { - return nil -} - -func (s *stdioConn) SetReadDeadline(t time.Time) error { - return nil -} - -func (s *stdioConn) SetWriteDeadline(t time.Time) error { - return nil -} - -// discardSSHConnection accepts a connection then outputs the message provided -// to any channel opened, immediately closing the connection afterwards. -// -// Used to provide status to connecting clients while still aligning with the -// native SSH protocol. -func discardSSHConnection(nc net.Conn, privateKey string, msg string) error { - config := &ssh.ServerConfig{ - NoClientAuth: true, - } - key, err := ssh.ParseRawPrivateKey([]byte(privateKey)) - if err != nil { - return fmt.Errorf("parse private key: %w", err) - } - signer, err := ssh.NewSignerFromKey(key) - if err != nil { - return fmt.Errorf("signer from private key: %w", err) - } - config.AddHostKey(signer) - conn, chans, reqs, err := ssh.NewServerConn(nc, config) - if err != nil { - return fmt.Errorf("create server conn: %w", err) - } - go ssh.DiscardRequests(reqs) - ch, req, err := (<-chans).Accept() - if err != nil { - return fmt.Errorf("accept channel: %w", err) - } - go ssh.DiscardRequests(req) - - _, err = ch.Write([]byte(msg)) - if err != nil { - return fmt.Errorf("write channel: %w", err) - } - err = ch.Close() - if err != nil { - return fmt.Errorf("close channel: %w", err) - } - return conn.Close() -} diff --git a/internal/cmd/urls.go b/internal/cmd/urls.go deleted file mode 100644 index b20d1794..00000000 --- a/internal/cmd/urls.go +++ /dev/null @@ -1,270 +0,0 @@ -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "regexp" - "strconv" - "strings" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func urlCmd() *cobra.Command { - var outputFmt string - cmd := &cobra.Command{ - Use: "urls", - Short: "Interact with workspace DevURLs", - } - lsCmd := &cobra.Command{ - Use: "ls [workspace_name]", - Short: "List all DevURLs for a workspace", - Args: xcobra.ExactArgs(1), - RunE: listDevURLsCmd(&outputFmt), - } - lsCmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human|json") - - rmCmd := &cobra.Command{ - Use: "rm [workspace_name] [port]", - Args: cobra.ExactArgs(2), - Short: "Remove a dev url", - RunE: removeDevURL, - } - - cmd.AddCommand( - lsCmd, - rmCmd, - createDevURLCmd(), - ) - - return cmd -} - -var urlAccessLevel = map[string]string{ - // Remote API endpoint requires these in uppercase. - "PRIVATE": "Only you can access", - "ORG": "All members of your organization can access", - "AUTHED": "Authenticated users can access", - "PUBLIC": "Anyone on the internet can access this link", -} - -func validatePort(port string) (int, error) { - p, err := strconv.ParseUint(port, 10, 16) - if err != nil { - clog.Log(clog.Error("invalid port")) - return 0, err - } - if p < 1 { - // Port 0 means 'any free port', which we don't support. - return 0, xerrors.New("Port must be > 0") - } - return int(p), nil -} - -func accessLevelIsValid(level string) bool { - _, ok := urlAccessLevel[level] - if !ok { - clog.Log(clog.Error("invalid access level")) - } - return ok -} - -// Run gets the list of active devURLs from the cemanager for the -// specified workspace and outputs info to stdout. -func listDevURLsCmd(outputFmt *string) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspaceName := args[0] - - devURLs, err := urlList(ctx, client, workspaceName) - if err != nil { - return err - } - - switch *outputFmt { - case humanOutput: - if len(devURLs) < 1 { - clog.LogInfo(fmt.Sprintf("no devURLs found for workspace %q", workspaceName)) - return nil - } - err := tablewriter.WriteTable(cmd.OutOrStdout(), len(devURLs), func(i int) interface{} { - return devURLs[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - case jsonOutput: - if err := json.NewEncoder(cmd.OutOrStdout()).Encode(devURLs); err != nil { - return xerrors.Errorf("encode DevURLs as json: %w", err) - } - default: - return xerrors.Errorf("unknown --output value %q", *outputFmt) - } - return nil - } -} - -func createDevURLCmd() *cobra.Command { - var ( - access string - urlname string - scheme string - ) - cmd := &cobra.Command{ - Use: "create [workspace_name] [port]", - Short: "Create a new dev URL for a workspace", - Aliases: []string{"edit"}, - Args: xcobra.ExactArgs(2), - Example: `coder urls create my-workspace 8080 --name my-dev-url`, - RunE: func(cmd *cobra.Command, args []string) error { - var ( - workspaceName = args[0] - port = args[1] - ctx = cmd.Context() - ) - - portNum, err := validatePort(port) - if err != nil { - return err - } - - access = strings.ToUpper(access) - if !accessLevelIsValid(access) { - return xerrors.Errorf("invalid access level %q", access) - } - - if urlname != "" && !devURLValidNameRx.MatchString(urlname) { - return xerrors.Errorf(devURLInvalidNameMsg, urlname) - } - client, err := newClient(ctx, true) - if err != nil { - return err - } - - workspace, err := findWorkspace(ctx, client, workspaceName, coder.Me) - if err != nil { - return err - } - - urls, err := urlList(ctx, client, workspaceName) - if err != nil { - return err - } - - urlID, found := devURLID(portNum, urls) - if found { - err := client.PutDevURL(ctx, workspace.ID, urlID, coder.PutDevURLReq{ - Port: portNum, - Name: urlname, - Access: access, - WorkspaceID: workspace.ID, - Scheme: scheme, - }) - if err != nil { - return xerrors.Errorf("update DevURL: %w", err) - } - clog.LogSuccess(fmt.Sprintf("patched devurl for port %s", port)) - } else { - err := client.CreateDevURL(ctx, workspace.ID, coder.CreateDevURLReq{ - Port: portNum, - Name: urlname, - Access: access, - WorkspaceID: workspace.ID, - Scheme: scheme, - }) - if err != nil { - return xerrors.Errorf("insert DevURL: %w", err) - } - clog.LogSuccess(fmt.Sprintf("created devurl for port %s", port)) - } - return nil - }, - } - - cmd.Flags().StringVar(&access, "access", "private", "Set DevURL access to [private | org | authed | public]") - cmd.Flags().StringVar(&urlname, "name", "", "DevURL name") - cmd.Flags().StringVar(&scheme, "scheme", "http", "Server scheme (http|https)") - return cmd -} - -// devURLNameValidRx is the regex used to validate devurl names specified -// via the --name subcommand. Named devurls must begin with a letter -// followed by zero or more letters, numbers, hyphens, or underscores, -// end with a letter or a number, and be maximum 64 characters in length. -// The maximum length of the name component is 43 characters. -var devURLValidNameRx = regexp.MustCompile("^[a-zA-Z]([a-zA-Z0-9_-]{0,41}[a-zA-Z0-9])?$") -var devURLInvalidNameMsg = "invalid devurl name %q: names must begin with a letter, " + - "followed by zero or more letters, digits, hyphens, or underscores, and end with a " + - "letter or digit, and be a maximum of 43 characters in length." - -// devURLID returns the ID of a devURL, given the workspace name and port -// from a list of DevURL records. -// ("", false) is returned if no match is found. -func devURLID(port int, urls []coder.DevURL) (string, bool) { - for _, url := range urls { - if url.Port == port { - return url.ID, true - } - } - return "", false -} - -// Run deletes a devURL, specified by workspace ID and port, from the cemanager. -func removeDevURL(cmd *cobra.Command, args []string) error { - var ( - workspaceName = args[0] - port = args[1] - ctx = cmd.Context() - ) - - portNum, err := validatePort(port) - if err != nil { - return xerrors.Errorf("validate port: %w", err) - } - - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspace, err := findWorkspace(ctx, client, workspaceName, coder.Me) - if err != nil { - return err - } - - urls, err := urlList(ctx, client, workspaceName) - if err != nil { - return err - } - - urlID, found := devURLID(portNum, urls) - if found { - clog.LogInfo(fmt.Sprintf("deleting devurl for port %v", port)) - } else { - return xerrors.Errorf("No devurl found for port %v", port) - } - - if err := client.DeleteDevURL(ctx, workspace.ID, urlID); err != nil { - return xerrors.Errorf("delete DevURL: %w", err) - } - return nil -} - -// urlList returns the list of active devURLs from the cemanager. -func urlList(ctx context.Context, client coder.Client, workspaceName string) ([]coder.DevURL, error) { - workspace, err := findWorkspace(ctx, client, workspaceName, coder.Me) - if err != nil { - return nil, err - } - return client.DevURLs(ctx, workspace.ID) -} diff --git a/internal/cmd/users.go b/internal/cmd/users.go deleted file mode 100644 index c9a00343..00000000 --- a/internal/cmd/users.go +++ /dev/null @@ -1,61 +0,0 @@ -package cmd - -import ( - "encoding/json" - - "github.com/spf13/cobra" - "golang.org/x/xerrors" - - "cdr.dev/coder-cli/pkg/tablewriter" -) - -func usersCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "users", - Short: "Interact with Coder user accounts", - } - - var outputFmt string - lsCmd := &cobra.Command{ - Use: "ls", - Short: "list all user accounts", - Example: `coder users ls -o json -coder users ls -o json | jq .[] | jq -r .email`, - RunE: listUsers(&outputFmt), - } - lsCmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human | json") - - cmd.AddCommand(lsCmd) - return cmd -} - -func listUsers(outputFmt *string) func(cmd *cobra.Command, args []string) error { - return func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - users, err := client.Users(ctx) - if err != nil { - return xerrors.Errorf("get users: %w", err) - } - - switch *outputFmt { - case humanOutput: - // For each element, return the user. - each := func(i int) interface{} { return users[i] } - if err := tablewriter.WriteTable(cmd.OutOrStdout(), len(users), each); err != nil { - return xerrors.Errorf("write table: %w", err) - } - case "json": - if err := json.NewEncoder(cmd.OutOrStdout()).Encode(users); err != nil { - return xerrors.Errorf("encode users as json: %w", err) - } - default: - return xerrors.New("unknown value for --output") - } - return nil - } -} diff --git a/internal/cmd/users_test.go b/internal/cmd/users_test.go deleted file mode 100644 index a82f4607..00000000 --- a/internal/cmd/users_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package cmd - -import ( - "testing" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - - "cdr.dev/coder-cli/coder-sdk" -) - -func Test_users(t *testing.T) { - skipIfNoAuth(t) - - var users []coder.User - res := execute(t, nil, "users", "ls", "--output=json") - res.success(t) - res.stdoutUnmarshals(t, &users) - assertAdmin(t, users) - - res = execute(t, nil, "users", "ls", "--output=human") - res.success(t) -} - -func assertAdmin(t *testing.T, users []coder.User) { - for _, u := range users { - if u.Username == "admin" { - return - } - } - slogtest.Fatal(t, "did not find admin user", slog.F("users", users)) -} diff --git a/internal/cmd/workspaces.go b/internal/cmd/workspaces.go deleted file mode 100644 index ff70b64d..00000000 --- a/internal/cmd/workspaces.go +++ /dev/null @@ -1,1045 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "io/ioutil" - "os" - "strings" - "time" - - "nhooyr.io/websocket" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/internal/x/xcobra" - "cdr.dev/coder-cli/pkg/clog" - "cdr.dev/coder-cli/pkg/tablewriter" - "cdr.dev/coder-cli/wsnet" - - "github.com/fatih/color" - "github.com/manifoldco/promptui" - "github.com/pion/ice/v2" - "github.com/pion/webrtc/v3" - "github.com/spf13/cobra" - "golang.org/x/xerrors" -) - -const defaultImgTag = "latest" - -func envCmd() *cobra.Command { - cmd := workspacesCmd() - cmd.Use = "envs" - cmd.Deprecated = "use \"workspaces\" instead" - cmd.Aliases = []string{} - return cmd -} - -func workspacesCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "workspaces", - Short: "Interact with Coder workspaces", - Long: "Perform operations on the Coder workspaces owned by the active user.", - Aliases: []string{"ws"}, - } - - cmd.AddCommand( - createWorkspaceCmd(), - editWorkspaceCmd(), - lsWorkspacesCommand(), - pingWorkspaceCommand(), - rebuildWorkspaceCommand(), - rmWorkspacesCmd(), - setPolicyTemplate(), - stopWorkspacesCmd(), - watchBuildLogCommand(), - workspaceFromConfigCmd(false), - workspaceFromConfigCmd(true), - ) - return cmd -} - -const ( - humanOutput = "human" - jsonOutput = "json" -) - -func lsWorkspacesCommand() *cobra.Command { - var ( - outputFmt string - user string - provider string - ) - - cmd := &cobra.Command{ - Use: "ls", - Short: "list all workspaces owned by the active user", - Long: "List all Coder workspaces owned by the active user.", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspaces, err := getWorkspaces(ctx, client, user) - if err != nil { - return err - } - if provider != "" { - workspaces, err = getWorkspacesByProvider(ctx, client, provider, user) - if err != nil { - return err - } - } - if len(workspaces) < 1 { - clog.LogInfo("no workspaces found") - workspaces = []coder.Workspace{} // ensures that json output still marshals - } - - switch outputFmt { - case humanOutput: - workspaces, err := coderutil.WorkspacesHumanTable(ctx, client, workspaces) - if err != nil { - return err - } - err = tablewriter.WriteTable(cmd.OutOrStdout(), len(workspaces), func(i int) interface{} { - return workspaces[i] - }) - if err != nil { - return xerrors.Errorf("write table: %w", err) - } - case jsonOutput: - err := json.NewEncoder(cmd.OutOrStdout()).Encode(workspaces) - if err != nil { - return xerrors.Errorf("write workspaces as JSON: %w", err) - } - default: - return xerrors.Errorf("unknown --output value %q", outputFmt) - } - return nil - }, - } - - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - cmd.Flags().StringVarP(&outputFmt, "output", "o", humanOutput, "human | json") - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Filter workspaces by a particular workspace provider name.") - - return cmd -} - -func pingWorkspaceCommand() *cobra.Command { - var ( - schemes []string - count int - ) - - cmd := &cobra.Command{ - Use: "ping ", - Short: "ping Coder workspaces by name", - Long: "ping Coder workspaces by name", - Example: `coder workspaces ping front-end-workspace`, - Args: xcobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - workspace, err := findWorkspace(ctx, client, args[0], coder.Me) - if err != nil { - return err - } - - iceSchemes := map[ice.SchemeType]interface{}{} - for _, rawScheme := range schemes { - scheme := ice.NewSchemeType(rawScheme) - if scheme == ice.Unknown { - return fmt.Errorf("scheme type %q not recognized", rawScheme) - } - iceSchemes[scheme] = nil - } - - pinger := &wsPinger{ - client: client, - workspace: workspace, - iceSchemes: iceSchemes, - } - - seq := 0 - ticker := time.NewTicker(time.Second) - for { - select { - case <-ticker.C: - err := pinger.ping(ctx) - if err != nil { - return err - } - seq++ - if count > 0 && seq >= count { - os.Exit(0) - } - case <-ctx.Done(): - return nil - } - } - }, - } - - cmd.Flags().StringSliceVarP(&schemes, "scheme", "s", []string{"stun", "stuns", "turn", "turns"}, "customize schemes to filter ice servers") - cmd.Flags().IntVarP(&count, "count", "c", 0, "stop after replies") - return cmd -} - -type wsPinger struct { - client coder.Client - workspace *coder.Workspace - dialer *wsnet.Dialer - iceSchemes map[ice.SchemeType]interface{} - tunneled bool -} - -func (*wsPinger) logFail(msg string) { - fmt.Printf("%s: %s\n", color.New(color.Bold, color.FgRed).Sprint("——"), msg) -} - -func (*wsPinger) logSuccess(timeStr, msg string) { - fmt.Printf("%s: %s\n", color.New(color.Bold, color.FgGreen).Sprint(timeStr), msg) -} - -// Only return fatal errors -func (w *wsPinger) ping(ctx context.Context) error { - ctx, cancelFunc := context.WithTimeout(ctx, time.Second*15) - defer cancelFunc() - url := w.client.BaseURL() - - // If the dialer is nil we create a new! - // nolint:nestif - if w.dialer == nil { - servers, err := w.client.ICEServers(ctx) - if err != nil { - w.logFail(fmt.Sprintf("list ice servers: %s", err.Error())) - return nil - } - filteredServers := make([]webrtc.ICEServer, 0, len(servers)) - for _, server := range servers { - good := true - for _, rawURL := range server.URLs { - url, err := ice.ParseURL(rawURL) - if err != nil { - return fmt.Errorf("parse url %q: %w", rawURL, err) - } - if _, ok := w.iceSchemes[url.Scheme]; !ok { - good = false - } - } - if good { - filteredServers = append(filteredServers, server) - } - } - if len(filteredServers) == 0 { - schemes := make([]string, 0) - for scheme := range w.iceSchemes { - schemes = append(schemes, scheme.String()) - } - return fmt.Errorf("no ice servers match the schemes provided: %s", strings.Join(schemes, ",")) - } - workspace, err := w.client.WorkspaceByID(ctx, w.workspace.ID) - if err != nil { - return err - } - if workspace.LatestStat.ContainerStatus != coder.WorkspaceOn { - w.logFail(fmt.Sprintf("workspace is unreachable (status=%s)", workspace.LatestStat.ContainerStatus)) - return nil - } - connectStart := time.Now() - w.dialer, err = wsnet.DialWebsocket(ctx, wsnet.ConnectEndpoint(&url, w.workspace.ID, w.client.Token()), &wsnet.DialOptions{ - ICEServers: filteredServers, - TURNProxyAuthToken: w.client.Token(), - TURNRemoteProxyURL: &url, - TURNLocalProxyURL: &url, - }, &websocket.DialOptions{}) - if err != nil { - w.logFail(fmt.Sprintf("dial workspace: %s", err.Error())) - return nil - } - connectMS := float64(time.Since(connectStart).Microseconds()) / 1000 - - candidates, err := w.dialer.Candidates() - if err != nil { - return err - } - isRelaying := candidates.Local.Typ == webrtc.ICECandidateTypeRelay - w.tunneled = false - candidateURLs := []string{} - - for _, server := range filteredServers { - if server.Username == wsnet.TURNProxyICECandidate().Username { - candidateURLs = append(candidateURLs, fmt.Sprintf("turn:%s", url.Host)) - if !isRelaying { - continue - } - w.tunneled = true - continue - } - - candidateURLs = append(candidateURLs, server.URLs...) - } - - connectionText := "direct via STUN" - if isRelaying { - connectionText = "proxied via TURN" - } - if w.tunneled { - connectionText = fmt.Sprintf("proxied via %s", url.Host) - } - w.logSuccess("——", fmt.Sprintf( - "connected in %.2fms (%s) candidates=%s", - connectMS, - connectionText, - strings.Join(candidateURLs, ","), - )) - } - - pingStart := time.Now() - err := w.dialer.Ping(ctx) - if err != nil { - if errors.Is(err, io.EOF) { - w.dialer = nil - w.logFail("connection timed out") - return nil - } - if errors.Is(err, webrtc.ErrConnectionClosed) { - w.dialer = nil - w.logFail("webrtc connection is closed") - return nil - } - return fmt.Errorf("ping workspace: %w", err) - } - pingMS := float64(time.Since(pingStart).Microseconds()) / 1000 - connectionText := "you ↔ workspace" - if w.tunneled { - connectionText = fmt.Sprintf("you ↔ %s ↔ workspace", url.Host) - } - w.logSuccess(fmt.Sprintf("%.2fms", pingMS), connectionText) - return nil -} - -func stopWorkspacesCmd() *cobra.Command { - var user string - cmd := &cobra.Command{ - Use: "stop [...workspace_names]", - Short: "stop Coder workspaces by name", - Long: "Stop Coder workspaces by name", - Example: `coder workspaces stop front-end-workspace -coder workspaces stop front-end-workspace backend-workspace - -# stop all of your workspaces -coder workspaces ls -o json | jq -c '.[].name' | xargs coder workspaces stop - -# stop all workspaces for a given user -coder workspaces --user charlie@coder.com ls -o json \ - | jq -c '.[].name' \ - | xargs coder workspaces --user charlie@coder.com stop`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return xerrors.Errorf("new client: %w", err) - } - - egroup := clog.LoggedErrGroup() - for _, workspaceName := range args { - workspaceName := workspaceName - egroup.Go(func() error { - workspace, err := findWorkspace(ctx, client, workspaceName, user) - if err != nil { - return err - } - - if err = client.StopWorkspace(ctx, workspace.ID); err != nil { - return clog.Error(fmt.Sprintf("stop workspace %q", workspace.Name), - clog.Causef(err.Error()), clog.BlankLine, - clog.Hintf("current workspace status is %q", workspace.LatestStat.ContainerStatus), - ) - } - clog.LogSuccess(fmt.Sprintf("successfully stopped workspace %q", workspaceName)) - return nil - }) - } - - return egroup.Wait() - }, - } - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - return cmd -} - -func createWorkspaceCmd() *cobra.Command { - var ( - org string - cpu float32 - memory float32 - disk int - gpus int - img string - tag string - follow bool - useCVM bool - providerName string - enableAutostart bool - ) - - cmd := &cobra.Command{ - Use: "create [workspace_name]", - Short: "create a new workspace.", - Args: xcobra.ExactArgs(1), - Long: "Create a new Coder workspace.", - Example: `# create a new workspace using default resource amounts -coder workspaces create my-new-workspace --image ubuntu -coder workspaces create my-new-powerful-workspace --cpu 12 --disk 100 --memory 16 --image ubuntu`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - if img == "" { - return xerrors.New("image unset") - } - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - multiOrgMember, err := isMultiOrgMember(ctx, client, coder.Me) - if err != nil { - return err - } - - if multiOrgMember && org == "" { - return xerrors.New("org is required for multi-org members") - } - importedImg, err := findImg(ctx, client, findImgConf{ - email: coder.Me, - imgName: img, - orgName: org, - }) - if err != nil { - return err - } - - var provider *coder.KubernetesProvider - if providerName == "" { - provider, err = coderutil.DefaultWorkspaceProvider(ctx, client) - if err != nil { - return xerrors.Errorf("default workspace provider: %w", err) - } - } else { - provider, err = coderutil.ProviderByName(ctx, client, providerName) - if err != nil { - return xerrors.Errorf("provider by name: %w", err) - } - } - - // ExactArgs(1) ensures our name value can't panic on an out of bounds. - createReq := &coder.CreateWorkspaceRequest{ - Name: args[0], - ImageID: importedImg.ID, - OrgID: importedImg.OrganizationID, - ImageTag: tag, - CPUCores: cpu, - MemoryGB: memory, - DiskGB: disk, - GPUs: gpus, - UseContainerVM: useCVM, - ResourcePoolID: provider.ID, - Namespace: provider.DefaultNamespace, - EnableAutoStart: enableAutostart, - } - - // if any of these defaulted to their zero value we provision - // the create request with the imported image defaults instead. - if createReq.CPUCores == 0 { - createReq.CPUCores = importedImg.DefaultCPUCores - } - if createReq.MemoryGB == 0 { - createReq.MemoryGB = importedImg.DefaultMemoryGB - } - if createReq.DiskGB == 0 { - createReq.DiskGB = importedImg.DefaultDiskGB - } - - workspace, err := client.CreateWorkspace(ctx, *createReq) - if err != nil { - return xerrors.Errorf("create workspace: %w", err) - } - - if follow { - clog.LogSuccess("creating workspace...") - if err := trailBuildLogs(ctx, client, workspace.ID); err != nil { - return err - } - return nil - } - - clog.LogSuccess("creating workspace...", - clog.BlankLine, - clog.Tipf(`run "coder workspaces watch-build %s" to trail the build logs`, workspace.Name), - ) - return nil - }, - } - cmd.Flags().StringVarP(&org, "org", "o", "", "name of the organization the workspace should be created under.") - cmd.Flags().StringVarP(&tag, "tag", "t", defaultImgTag, "tag of the image the workspace will be based off of.") - cmd.Flags().Float32VarP(&cpu, "cpu", "c", 0, "number of cpu cores the workspace should be provisioned with.") - cmd.Flags().Float32VarP(&memory, "memory", "m", 0, "GB of RAM a workspace should be provisioned with.") - cmd.Flags().IntVarP(&disk, "disk", "d", 0, "GB of disk storage a workspace should be provisioned with.") - cmd.Flags().IntVarP(&gpus, "gpus", "g", 0, "number GPUs a workspace should be provisioned with.") - cmd.Flags().StringVarP(&img, "image", "i", "", "name of the image to base the workspace off of.") - cmd.Flags().StringVar(&providerName, "provider", "", "name of Workspace Provider with which to create the workspace") - cmd.Flags().BoolVar(&follow, "follow", false, "follow buildlog after initiating rebuild") - cmd.Flags().BoolVar(&useCVM, "container-based-vm", false, "deploy the workspace as a Container-based VM") - cmd.Flags().BoolVar(&enableAutostart, "enable-autostart", false, "automatically start this workspace at your preferred time.") - _ = cmd.MarkFlagRequired("image") - return cmd -} - -// selectOrg finds the organization in the list or returns the default organization -// if the needle isn't found. -func selectOrg(needle string, haystack []coder.Organization) (*coder.Organization, error) { - var userOrg *coder.Organization - for i := range haystack { - // Look for org by name - if haystack[i].Name == needle { - userOrg = &haystack[i] - break - } - // Or use default if the provided is blank - if needle == "" && haystack[i].Default { - userOrg = &haystack[i] - break - } - } - - if userOrg == nil { - if needle != "" { - return nil, xerrors.Errorf("Unable to locate org '%s'", needle) - } - return nil, xerrors.Errorf("Unable to locate a default organization for the user") - } - return userOrg, nil -} - -// workspaceFromConfigCmd will return a create or an update workspace for a template'd workspace. -// The code for create/update is nearly identical. -// If `update` is true, the update command is returned. If false, the create command. -func workspaceFromConfigCmd(update bool) *cobra.Command { - var ( - ref string - repo string - follow bool - filepath string - org string - providerName string - envName string - ) - - run := func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - // Update requires the env name, and the name should be the first argument. - if update { - envName = args[0] - } else if envName == "" { - // Create takes the name as a flag, and it must be set - return clog.Error("Must provide a workspace name.", - clog.BlankLine, - clog.Tipf("Use --name= to name your workspace"), - ) - } - - client, err := newClient(ctx, true) - if err != nil { - return err - } - - orgs, err := getUserOrgs(ctx, client, coder.Me) - if err != nil { - return err - } - - multiOrgMember := len(orgs) > 1 - if multiOrgMember && org == "" { - return xerrors.New("org is required for multi-org members") - } - - // This is the env to be updated/created - var env *coder.Workspace - - // OrgID is the org where the template and env should be created. - // If we are updating an env, use the orgID from the workspace. - var orgID string - if update { - env, err = findWorkspace(ctx, client, envName, coder.Me) - if err != nil { - return handleAPIError(err) - } - orgID = env.OrganizationID - } else { - var userOrg *coder.Organization - // Select org in list or use default - userOrg, err := selectOrg(org, orgs) - if err != nil { - return err - } - - orgID = userOrg.ID - } - - if filepath == "" && ref == "" && repo == "" { - return clog.Error("Must specify a configuration source", - "A template source is either sourced from a local file (-f) or from a git repository (--repo-url and --ref)", - ) - } - - var rd io.Reader - if filepath != "" { - b, err := ioutil.ReadFile(filepath) - if err != nil { - return xerrors.Errorf("read local file: %w", err) - } - rd = bytes.NewReader(b) - } - - req := coder.ParseTemplateRequest{ - RepoURL: repo, - Ref: ref, - Local: rd, - OrgID: orgID, - Filepath: ".coder/coder.yaml", - } - - version, err := client.ParseTemplate(ctx, req) - if err != nil { - return handleAPIError(err) - } - - provider, err := coderutil.DefaultWorkspaceProvider(ctx, client) - if err != nil { - return xerrors.Errorf("default workspace provider: %w", err) - } - - if update { - err = client.EditWorkspace(ctx, env.ID, coder.UpdateWorkspaceReq{ - TemplateID: &version.TemplateID, - }) - } else { - env, err = client.CreateWorkspace(ctx, coder.CreateWorkspaceRequest{ - OrgID: orgID, - TemplateID: version.TemplateID, - ResourcePoolID: provider.ID, - Namespace: provider.DefaultNamespace, - Name: envName, - }) - } - if err != nil { - return handleAPIError(err) - } - - if follow { - clog.LogSuccess("creating workspace...") - if err := trailBuildLogs(ctx, client, env.ID); err != nil { - return err - } - return nil - } - - clog.LogSuccess("creating workspace...", - clog.BlankLine, - clog.Tipf(`run "coder envs watch-build %s" to trail the build logs`, env.Name), - ) - return nil - } - - var cmd *cobra.Command - if update { - cmd = &cobra.Command{ - Use: "edit-from-config", - Short: "change the template a workspace is tracking", - Long: "Edit an existing Coder workspace using a Workspaces As Code template.", - Args: cobra.ExactArgs(1), - Example: `# edit a new workspace from git repository -coder envs edit-from-config dev-env --repo-url https://github.com/cdr/m --ref my-branch -coder envs edit-from-config dev-env -f coder.yaml`, - RunE: run, - } - } else { - cmd = &cobra.Command{ - Use: "create-from-config", - Short: "create a new workspace from a template", - Long: "Create a new Coder workspace using a Workspaces As Code template.", - Example: `# create a new workspace from git repository -coder envs create-from-config --name="dev-env" --repo-url https://github.com/cdr/m --ref my-branch -coder envs create-from-config --name="dev-env" -f coder.yaml`, - RunE: run, - } - cmd.Flags().StringVar(&providerName, "provider", "", "name of Workspace Provider with which to create the workspace") - cmd.Flags().StringVar(&envName, "name", "", "name of the workspace to be created") - cmd.Flags().StringVarP(&org, "org", "o", "", "name of the organization the workspace should be created under.") - // Ref and repo-url can only be used for create - cmd.Flags().StringVarP(&ref, "ref", "", "master", "git reference to pull template from. May be a branch, tag, or commit hash.") - cmd.Flags().StringVarP(&repo, "repo-url", "r", "", "URL of the git repository to pull the config from. Config file must live in '.coder/coder.yaml'.") - } - - cmd.Flags().StringVarP(&filepath, "filepath", "f", "", "path to local template file.") - cmd.Flags().BoolVar(&follow, "follow", false, "follow buildlog after initiating rebuild") - return cmd -} - -func editWorkspaceCmd() *cobra.Command { - var ( - org string - img string - tag string - cpu float32 - memory float32 - disk int - gpus int - follow bool - user string - force bool - ) - - cmd := &cobra.Command{ - Use: "edit", - Short: "edit an existing workspace and initiate a rebuild.", - Args: xcobra.ExactArgs(1), - Long: "Edit an existing workspace and initate a rebuild.", - Example: `coder workspaces edit back-end-workspace --cpu 4 - -coder workspaces edit back-end-workspace --disk 20`, - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - workspaceName := args[0] - - workspace, err := findWorkspace(ctx, client, workspaceName, user) - if err != nil { - return err - } - - multiOrgMember, err := isMultiOrgMember(ctx, client, user) - if err != nil { - return err - } - - // if the user belongs to multiple organizations we need them to specify which one. - if multiOrgMember && org == "" { - return xerrors.New("org is required for multi-org members") - } - - req, err := buildUpdateReq(ctx, client, updateConf{ - cpu: cpu, - memGB: memory, - diskGB: disk, - gpus: gpus, - workspace: workspace, - user: user, - image: img, - imageTag: tag, - orgName: org, - }) - if err != nil { - return err - } - - if !force && workspace.LatestStat.ContainerStatus == coder.WorkspaceOn { - _, err = (&promptui.Prompt{ - Label: fmt.Sprintf("Rebuild workspace %q? (will destroy any work outside of your home directory)", workspace.Name), - IsConfirm: true, - }).Run() - if err != nil { - return clog.Fatal( - "failed to confirm prompt", clog.BlankLine, - clog.Tipf(`use "--force" to rebuild without a confirmation prompt`), - ) - } - } - - if err := client.EditWorkspace(ctx, workspace.ID, *req); err != nil { - return xerrors.Errorf("failed to apply changes to workspace %q: %w", workspaceName, err) - } - - if follow { - clog.LogSuccess("applied changes to the workspace, rebuilding...") - if err := trailBuildLogs(ctx, client, workspace.ID); err != nil { - return err - } - return nil - } - - clog.LogSuccess("applied changes to the workspace, rebuilding...", - clog.BlankLine, - clog.Tipf(`run "coder workspaces watch-build %s" to trail the build logs`, workspaceName), - ) - return nil - }, - } - cmd.Flags().StringVarP(&org, "org", "o", "", "name of the organization the workspace should be created under.") - cmd.Flags().StringVarP(&img, "image", "i", "", "name of the image you want the workspace to be based off of.") - cmd.Flags().StringVarP(&tag, "tag", "t", "latest", "image tag of the image you want to base the workspace off of.") - cmd.Flags().Float32VarP(&cpu, "cpu", "c", 0, "The number of cpu cores the workspace should be provisioned with.") - cmd.Flags().Float32VarP(&memory, "memory", "m", 0, "The amount of RAM a workspace should be provisioned with.") - cmd.Flags().IntVarP(&disk, "disk", "d", 0, "The amount of disk storage a workspace should be provisioned with.") - cmd.Flags().IntVarP(&gpus, "gpu", "g", 0, "The amount of disk storage to provision the workspace with.") - cmd.Flags().BoolVar(&follow, "follow", false, "follow buildlog after initiating rebuild") - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - cmd.Flags().BoolVar(&force, "force", false, "force rebuild without showing a confirmation prompt") - return cmd -} - -func rmWorkspacesCmd() *cobra.Command { - var ( - force bool - user string - ) - - cmd := &cobra.Command{ - Use: "rm [...workspace_names]", - Short: "remove Coder workspaces by name", - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - if !force { - confirm := promptui.Prompt{ - Label: fmt.Sprintf("Delete workspaces %q? (all data will be lost)", args), - IsConfirm: true, - } - if _, err := confirm.Run(); err != nil { - return clog.Fatal( - "failed to confirm deletion", clog.BlankLine, - clog.Tipf(`use "--force" to rebuild without a confirmation prompt`), - ) - } - } - - egroup := clog.LoggedErrGroup() - for _, workspaceName := range args { - workspaceName := workspaceName - egroup.Go(func() error { - workspace, err := findWorkspace(ctx, client, workspaceName, user) - if err != nil { - return err - } - if err = client.DeleteWorkspace(ctx, workspace.ID); err != nil { - return clog.Error( - fmt.Sprintf(`failed to delete workspace "%s"`, workspace.Name), - clog.Causef(err.Error()), - ) - } - clog.LogSuccess(fmt.Sprintf("deleted workspace %q", workspace.Name)) - return nil - }) - } - return egroup.Wait() - }, - } - cmd.Flags().BoolVarP(&force, "force", "f", false, "force remove the specified workspaces without prompting first") - cmd.Flags().StringVar(&user, "user", coder.Me, "Specify the user whose resources to target") - return cmd -} - -type updateConf struct { - cpu float32 - memGB float32 - diskGB int - gpus int - workspace *coder.Workspace - user string - image string - imageTag string - orgName string -} - -func buildUpdateReq(ctx context.Context, client coder.Client, conf updateConf) (*coder.UpdateWorkspaceReq, error) { - var ( - updateReq coder.UpdateWorkspaceReq - defaultCPUCores float32 - defaultMemGB float32 - defaultDiskGB int - ) - - // If this is not empty it means the user is requesting to change the workspace image. - if conf.image != "" { - importedImg, err := findImg(ctx, client, findImgConf{ - email: conf.user, - imgName: conf.image, - orgName: conf.orgName, - }) - if err != nil { - return nil, err - } - - // If the user passes an image arg of the image that - // the workspace is already using, it was most likely a mistake. - if conf.image != importedImg.Repository { - return nil, xerrors.Errorf("workspace is already using image %q", conf.image) - } - - // Since the workspace image is being changed, - // the resource amount defaults should be changed to - // reflect that of the default resource amounts of the new image. - defaultCPUCores = importedImg.DefaultCPUCores - defaultMemGB = importedImg.DefaultMemoryGB - defaultDiskGB = importedImg.DefaultDiskGB - updateReq.ImageID = &importedImg.ID - } else { - // if the workspace image is not being changed, the default - // resource amounts should reflect the default resource amounts - // of the image the workspace is already using. - defaultCPUCores = conf.workspace.CPUCores - defaultMemGB = conf.workspace.MemoryGB - defaultDiskGB = conf.workspace.DiskGB - updateReq.ImageID = &conf.workspace.ImageID - } - - // The following logic checks to see if the user specified - // any resource amounts for the workspace that need to be changed. - // If they did not, then we will get the zero value back - // and should set the resource amount to the default. - - if conf.cpu == 0 { - updateReq.CPUCores = &defaultCPUCores - } else { - updateReq.CPUCores = &conf.cpu - } - - if conf.memGB == 0 { - updateReq.MemoryGB = &defaultMemGB - } else { - updateReq.MemoryGB = &conf.memGB - } - - if conf.diskGB == 0 { - updateReq.DiskGB = &defaultDiskGB - } else { - updateReq.DiskGB = &conf.diskGB - } - - // Workspace disks can not be shrink so we have to overwrite this - // if the user accidentally requests it or if the default diskGB value for a - // newly requested image is smaller than the current amount the workspace is using. - if *updateReq.DiskGB < conf.workspace.DiskGB { - clog.LogWarn("disk can not be shrunk", - fmt.Sprintf("keeping workspace disk at %d GB", conf.workspace.DiskGB), - ) - updateReq.DiskGB = &conf.workspace.DiskGB - } - - if conf.gpus != 0 { - updateReq.GPUs = &conf.gpus - } - - if conf.imageTag == "" { - // We're forced to make an alloc here because untyped string consts are not addressable. - // i.e. updateReq.ImageTag = &defaultImgTag results in : - // invalid operation: cannot take address of defaultImgTag (untyped string constant "latest") - imgTag := defaultImgTag - updateReq.ImageTag = &imgTag - } else { - updateReq.ImageTag = &conf.imageTag - } - return &updateReq, nil -} - -func setPolicyTemplate() *cobra.Command { - var ( - ref string - repo string - filepath string - dryRun bool - defaultTemplate bool - scope string - ) - - cmd := &cobra.Command{ - Use: "policy-template", - Short: "Set workspace policy template", - Long: "Set workspace policy template or restore to default configuration. This feature is for site admins only.", - RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - client, err := newClient(ctx, true) - if err != nil { - return err - } - - if scope != coder.TemplateScopeSite { - return clog.Error("Invalid 'scope' value", "Valid scope values: site") - } - - if filepath == "" && !defaultTemplate { - return clog.Error("Missing required parameter --filepath or --default", "Must specify a template to set") - } - - templateID := "" - if filepath != "" { - var rd io.Reader - b, err := ioutil.ReadFile(filepath) - if err != nil { - return xerrors.Errorf("read local file: %w", err) - } - rd = bytes.NewReader(b) - - req := coder.ParseTemplateRequest{ - RepoURL: repo, - Ref: ref, - Local: rd, - OrgID: coder.SkipTemplateOrg, - Filepath: ".coder/coder.yaml", - } - - version, err := client.ParseTemplate(ctx, req) - if err != nil { - return handleAPIError(err) - } - templateID = version.TemplateID - } - - resp, err := client.SetPolicyTemplate(ctx, templateID, coder.TemplateScope(scope), dryRun) - if err != nil { - return handleAPIError(err) - } - - for _, mc := range resp.MergeConflicts { - workspace, err := client.WorkspaceByID(ctx, mc.WorkspaceID) - if err != nil { - fmt.Printf("Workspace %q:\n", mc.WorkspaceID) - } else { - fmt.Printf("Workspace %q in organization %q:\n", workspace.Name, workspace.OrganizationID) - } - - fmt.Println(mc.String()) - } - - fmt.Println("Summary:") - fmt.Println(coder.WorkspaceTemplateMergeConflicts(resp.MergeConflicts).Summary()) - - return nil - }, - } - cmd.Flags().BoolVarP(&dryRun, "dry-run", "", false, "skip setting policy template, but view errors/warnings about how this policy template would impact existing workspaces") - cmd.Flags().StringVarP(&filepath, "filepath", "f", "", "full path to local policy template file.") - cmd.Flags().StringVar(&scope, "scope", "site", "scope of impact for the policy template. Supported values: site") - cmd.Flags().BoolVar(&defaultTemplate, "default", false, "Restore policy template to default configuration") - return cmd -} diff --git a/internal/cmd/workspaces_test.go b/internal/cmd/workspaces_test.go deleted file mode 100644 index 5dd3d01b..00000000 --- a/internal/cmd/workspaces_test.go +++ /dev/null @@ -1,207 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "math" - "math/rand" - "os" - "testing" - "time" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - "cdr.dev/slog/sloggers/slogtest/assert" - "github.com/google/go-cmp/cmp" - - "cdr.dev/coder-cli/coder-sdk" -) - -func Test_workspaces_ls(t *testing.T) { - skipIfNoAuth(t) - res := execute(t, nil, "workspaces", "ls") - res.success(t) - - res = execute(t, nil, "workspaces", "ls", "--output=json") - res.success(t) - - var workspaces []coder.Workspace - res.stdoutUnmarshals(t, &workspaces) -} - -func Test_workspaces_ls_by_provider(t *testing.T) { - skipIfNoAuth(t) - for _, test := range []struct { - name string - command []string - assert func(r result) - }{ - { - name: "simple list", - command: []string{"workspaces", "ls", "--provider", "built-in"}, - assert: func(r result) { r.success(t) }, - }, - { - name: "list as json", - command: []string{"workspaces", "ls", "--provider", "built-in", "--output", "json"}, - assert: func(r result) { - var workspaces []coder.Workspace - r.stdoutUnmarshals(t, &workspaces) - }, - }, - } { - test := test - t.Run(test.name, func(t *testing.T) { - test.assert(execute(t, nil, test.command...)) - }) - } -} - -func Test_workspace_create(t *testing.T) { - skipIfNoAuth(t) - ctx := context.Background() - - // Minimum args not received. - res := execute(t, nil, "workspaces", "create") - res.error(t) - res.stderrContains(t, "accepts 1 arg(s), received 0") - - // Successfully output help. - res = execute(t, nil, "workspaces", "create", "--help") - res.success(t) - res.stdoutContains(t, "Create a new Coder workspace.") - - // Image unset - res = execute(t, nil, "workspaces", "create", "test-workspace") - res.error(t) - res.stderrContains(t, "fatal: required flag(s) \"image\" not set") - - // Image not imported - res = execute(t, nil, "workspaces", "create", "test-workspace", "--image=doestexist") - res.error(t) - res.stderrContains(t, "fatal: image not found - did you forget to import this image?") - - ensureImageImported(ctx, t, testCoderClient, "codercom/enterprise-base", "ubuntu") - - name := randString(10) - cpu := 2.3 - - // attempt to remove the workspace on cleanup - t.Cleanup(func() { _ = execute(t, nil, "workspaces", "rm", name, "--force") }) - - res = execute(t, nil, "workspaces", "create", name, "--image=codercom/enterprise-base", "--tag=ubuntu", fmt.Sprintf("--cpu=%f", cpu)) - res.success(t) - - res = execute(t, nil, "workspaces", "ls") - res.success(t) - res.stdoutContains(t, name) - - var workspaces []coder.Workspace - res = execute(t, nil, "workspaces", "ls", "--output=json") - res.success(t) - res.stdoutUnmarshals(t, &workspaces) - workspace := assertWorkspace(t, name, workspaces) - assert.Equal(t, "workspace cpu", cpu, float64(workspace.CPUCores), floatComparer) - - res = execute(t, nil, "workspaces", "watch-build", name) - res.success(t) - - // edit the CPU of the workspace - cpu = 2.1 - res = execute(t, nil, "workspaces", "edit", name, "--image=codercom/enterprise-base", "--tag=ubuntu", fmt.Sprintf("--cpu=%f", cpu), "--follow", "--force") - res.success(t) - - // assert that the CPU actually did change after edit - res = execute(t, nil, "workspaces", "ls", "--output=json") - res.success(t) - res.stdoutUnmarshals(t, &workspaces) - workspace = assertWorkspace(t, name, workspaces) - assert.Equal(t, "workspace cpu", cpu, float64(workspace.CPUCores), floatComparer) - - res = execute(t, nil, "workspaces", "rm", name, "--force") - res.success(t) -} - -func assertWorkspace(t *testing.T, name string, workspaces []coder.Workspace) *coder.Workspace { - for _, e := range workspaces { - if name == e.Name { - return &e - } - } - slogtest.Fatal(t, "workspace not found", slog.F("name", name), slog.F("workspaces", workspaces)) - return nil -} - -var seededRand = rand.New(rand.NewSource(time.Now().UnixNano())) - -//nolint:unparam -func randString(length int) string { - const charset = "abcdefghijklmnopqrstuvwxyz" - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} - -var floatComparer = cmp.Comparer(func(x, y float64) bool { - delta := math.Abs(x - y) - mean := math.Abs(x+y) / 2.0 - return delta/mean < 0.001 -}) - -// this is a stopgap until we have support for a `coder images` subcommand -// until then, we can use the coder.Client to ensure our integration tests -// work on fresh deployments. -func ensureImageImported(ctx context.Context, t *testing.T, client coder.Client, img, tag string) { - orgs, err := client.Organizations(ctx) - assert.Success(t, "get orgs", err) - - var org *coder.Organization -search: - for _, o := range orgs { - for _, m := range o.Members { - if m.Email == os.Getenv("CODER_EMAIL") { - o := o - org = &o - break search - } - } - } - if org == nil { - slogtest.Fatal(t, "failed to find org of current user") - return // help the linter out a bit - } - - registries, err := client.Registries(ctx, org.ID) - assert.Success(t, "get registries", err) - - var dockerhubID string - for _, r := range registries { - if r.Registry == "index.docker.io" { - dockerhubID = r.ID - } - } - assert.True(t, "docker hub registry found", dockerhubID != "") - - imgs, err := client.OrganizationImages(ctx, org.ID) - assert.Success(t, "get org images", err) - found := false - for _, i := range imgs { - if i.Repository == img { - found = true - } - } - if !found { - // ignore this error for now as it causes a race with other parallel tests - _, _ = client.ImportImage(ctx, coder.ImportImageReq{ - RegistryID: &dockerhubID, - OrgID: org.ID, - Repository: img, - Tag: tag, - DefaultCPUCores: 2.5, - DefaultDiskGB: 22, - DefaultMemoryGB: 3, - }) - } -} diff --git a/internal/coderutil/doc.go b/internal/coderutil/doc.go deleted file mode 100644 index 5a7d8e14..00000000 --- a/internal/coderutil/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package coderutil providers utilities for high-level operations on coder-sdk entities. -package coderutil diff --git a/internal/coderutil/provider.go b/internal/coderutil/provider.go deleted file mode 100644 index 5364add8..00000000 --- a/internal/coderutil/provider.go +++ /dev/null @@ -1,21 +0,0 @@ -package coderutil - -import ( - "context" - - "cdr.dev/coder-cli/coder-sdk" -) - -// ProviderByName searches linearly for a workspace provider by its name. -func ProviderByName(ctx context.Context, client coder.Client, name string) (*coder.KubernetesProvider, error) { - providers, err := client.WorkspaceProviders(ctx) - if err != nil { - return nil, err - } - for _, p := range providers.Kubernetes { - if p.Name == name { - return &p, nil - } - } - return nil, coder.ErrNotFound -} diff --git a/internal/coderutil/workspace.go b/internal/coderutil/workspace.go deleted file mode 100644 index b81ad964..00000000 --- a/internal/coderutil/workspace.go +++ /dev/null @@ -1,162 +0,0 @@ -package coderutil - -import ( - "context" - "fmt" - "net/url" - "sync" - - "golang.org/x/xerrors" - "nhooyr.io/websocket" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/pkg/clog" -) - -// DialWorkspaceWsep dials the executor endpoint using the https://github.com/cdr/wsep message protocol. -// The proper workspace provider envproxy access URL is used. -func DialWorkspaceWsep(ctx context.Context, client coder.Client, workspace *coder.Workspace) (*websocket.Conn, error) { - workspaceProvider, err := client.WorkspaceProviderByID(ctx, workspace.ResourcePoolID) - if err != nil { - return nil, xerrors.Errorf("get workspace workspace provider: %w", err) - } - accessURL, err := url.Parse(workspaceProvider.EnvproxyAccessURL) - if err != nil { - return nil, xerrors.Errorf("invalid workspace provider envproxy access url: %w", err) - } - - conn, err := client.DialWsep(ctx, accessURL, workspace.ID) - if err != nil { - return nil, xerrors.Errorf("dial websocket: %w", err) - } - return conn, nil -} - -// WorkspaceWithWorkspaceProvider composes an Workspace entity with its associated WorkspaceProvider. -type WorkspaceWithWorkspaceProvider struct { - Workspace coder.Workspace - WorkspaceProvider coder.KubernetesProvider -} - -// WorkspacesWithProvider performs the composition of each Workspace with its associated WorkspaceProvider. -func WorkspacesWithProvider(ctx context.Context, client coder.Client, workspaces []coder.Workspace) ([]WorkspaceWithWorkspaceProvider, error) { - pooledWorkspaces := make([]WorkspaceWithWorkspaceProvider, 0, len(workspaces)) - providers, err := client.WorkspaceProviders(ctx) - if err != nil { - return nil, err - } - providerMap := make(map[string]coder.KubernetesProvider, len(providers.Kubernetes)) - for _, p := range providers.Kubernetes { - providerMap[p.ID] = p - } - for _, e := range workspaces { - workspaceProvider, ok := providerMap[e.ResourcePoolID] - if !ok { - return nil, xerrors.Errorf("fetch workspace workspace provider: %w", coder.ErrNotFound) - } - pooledWorkspaces = append(pooledWorkspaces, WorkspaceWithWorkspaceProvider{ - Workspace: e, - WorkspaceProvider: workspaceProvider, - }) - } - return pooledWorkspaces, nil -} - -// DefaultWorkspaceProvider returns the default provider with which to create workspaces. -func DefaultWorkspaceProvider(ctx context.Context, c coder.Client) (*coder.KubernetesProvider, error) { - provider, err := c.WorkspaceProviders(ctx) - if err != nil { - return nil, err - } - for _, p := range provider.Kubernetes { - if p.BuiltIn { - return &p, nil - } - } - return nil, coder.ErrNotFound -} - -// WorkspaceTable defines an Workspace-like structure with associated entities composed in a human -// readable form. -type WorkspaceTable struct { - Name string `table:"Name"` - Image string `table:"Image"` - CPU float32 `table:"vCPU"` - MemoryGB float32 `table:"MemoryGB"` - DiskGB int `table:"DiskGB"` - Status string `table:"Status"` - Provider string `table:"Provider"` - CVM bool `table:"CVM"` -} - -// WorkspacesHumanTable performs the composition of each Workspace with its associated ProviderName and ImageRepo. -func WorkspacesHumanTable(ctx context.Context, client coder.Client, workspaces []coder.Workspace) ([]WorkspaceTable, error) { - imageMap, err := MakeImageMap(ctx, client, workspaces) - if err != nil { - return nil, err - } - - pooledWorkspaces := make([]WorkspaceTable, 0, len(workspaces)) - providers, err := client.WorkspaceProviders(ctx) - if err != nil { - return nil, err - } - providerMap := make(map[string]coder.KubernetesProvider, len(providers.Kubernetes)) - for _, p := range providers.Kubernetes { - providerMap[p.ID] = p - } - for _, e := range workspaces { - workspaceProvider, ok := providerMap[e.ResourcePoolID] - if !ok { - return nil, xerrors.Errorf("fetch workspace workspace provider: %w", coder.ErrNotFound) - } - pooledWorkspaces = append(pooledWorkspaces, WorkspaceTable{ - Name: e.Name, - Image: fmt.Sprintf("%s:%s", imageMap[e.ImageID].Repository, e.ImageTag), - CPU: e.CPUCores, - MemoryGB: e.MemoryGB, - DiskGB: e.DiskGB, - Status: string(e.LatestStat.ContainerStatus), - Provider: workspaceProvider.Name, - CVM: e.UseContainerVM, - }) - } - return pooledWorkspaces, nil -} - -// MakeImageMap fetches all image entities specified in the slice of workspaces, then places them into an ID map. -func MakeImageMap(ctx context.Context, client coder.Client, workspaces []coder.Workspace) (map[string]*coder.Image, error) { - var ( - mu sync.Mutex - egroup = clog.LoggedErrGroup() - ) - imageMap := make(map[string]*coder.Image) - for _, e := range workspaces { - // put all the image IDs into a map to remove duplicates - imageMap[e.ImageID] = nil - } - ids := make([]string, 0, len(imageMap)) - for id := range imageMap { - // put the deduplicated back into a slice - // so we can write to the map while iterating - ids = append(ids, id) - } - for _, id := range ids { - id := id - egroup.Go(func() error { - img, err := client.ImageByID(ctx, id) - if err != nil { - return err - } - mu.Lock() - defer mu.Unlock() - imageMap[id] = img - - return nil - }) - } - if err := egroup.Wait(); err != nil { - return nil, err - } - return imageMap, nil -} diff --git a/internal/config/dir.go b/internal/config/dir.go deleted file mode 100644 index aff69fca..00000000 --- a/internal/config/dir.go +++ /dev/null @@ -1,52 +0,0 @@ -package config - -import ( - "io/ioutil" - "os" - "path/filepath" - - "github.com/kirsle/configdir" -) - -var configRoot = configdir.LocalConfig("coder") - -// SetRoot overrides the package-level config root configuration. -func SetRoot(root string) { - configRoot = root -} - -// open opens a file in the configuration directory, -// creating all intermediate directories. -func open(path string, flag int, mode os.FileMode) (*os.File, error) { - path = filepath.Join(configRoot, path) - - err := os.MkdirAll(filepath.Dir(path), 0750) - if err != nil { - return nil, err - } - - return os.OpenFile(path, flag, mode) -} - -func write(path string, mode os.FileMode, dat []byte) error { - fi, err := open(path, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, mode) - if err != nil { - return err - } - defer fi.Close() - _, err = fi.Write(dat) - return err -} - -func read(path string) ([]byte, error) { - fi, err := open(path, os.O_RDONLY, 0) - if err != nil { - return nil, err - } - defer fi.Close() - return ioutil.ReadAll(fi) -} - -func rm(path string) error { - return os.Remove(filepath.Join(configRoot, path)) -} diff --git a/internal/config/doc.go b/internal/config/doc.go deleted file mode 100644 index 69ff5641..00000000 --- a/internal/config/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package config provides facilities for working with the local configuration -// directory. -package config diff --git a/internal/config/file.go b/internal/config/file.go deleted file mode 100644 index 8ef1a910..00000000 --- a/internal/config/file.go +++ /dev/null @@ -1,26 +0,0 @@ -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/sync/eventcache.go b/internal/sync/eventcache.go deleted file mode 100644 index 1073b123..00000000 --- a/internal/sync/eventcache.go +++ /dev/null @@ -1,61 +0,0 @@ -package sync - -import ( - "os" - "time" - - "github.com/rjeczalik/notify" -) - -type timedEvent struct { - CreatedAt time.Time - notify.EventInfo -} - -type eventCache map[string]timedEvent - -func (cache eventCache) Add(ev timedEvent) { - lastEvent, ok := cache[ev.Path()] - if ok { - // If the file was quickly created and then destroyed, pretend nothing ever happened. - if lastEvent.Event() == notify.Create && ev.Event() == notify.Remove { - delete(cache, ev.Path()) - return - } - } - // Only let the latest event for a path have action. - cache[ev.Path()] = ev -} - -// SequentialEvents returns the list of events that pertain to directories. -// The set of returned events is disjoint with ConcurrentEvents. -func (cache eventCache) SequentialEvents() []timedEvent { - var r []timedEvent - for _, ev := range cache { - info, err := os.Stat(ev.Path()) - if err == nil && !info.IsDir() { - continue - } - // Include files that have deleted here. - // It's unclear whether they're files or folders. - r = append(r, ev) - } - return r -} - -// ConcurrentEvents returns the list of events that are safe to process after SequentialEvents. -// The set of returns events is disjoint with SequentialEvents. -func (cache eventCache) ConcurrentEvents() []timedEvent { - var r []timedEvent - for _, ev := range cache { - info, err := os.Stat(ev.Path()) - if err != nil { - continue - } - if info.IsDir() { - continue - } - r = append(r, ev) - } - return r -} diff --git a/internal/sync/singlefile.go b/internal/sync/singlefile.go deleted file mode 100644 index adf8c290..00000000 --- a/internal/sync/singlefile.go +++ /dev/null @@ -1,60 +0,0 @@ -package sync - -import ( - "context" - "fmt" - "io" - "io/ioutil" - "os" - "path/filepath" - "strings" - - "cdr.dev/wsep" - "golang.org/x/xerrors" - "nhooyr.io/websocket" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/coderutil" -) - -// SingleFile copies the given file into the remote dir or remote path of the given coder.Workspace. -func SingleFile(ctx context.Context, local, remoteDir string, workspace *coder.Workspace, client coder.Client) error { - conn, err := coderutil.DialWorkspaceWsep(ctx, client, workspace) - if err != nil { - return xerrors.Errorf("dial remote execer: %w", err) - } - defer func() { _ = conn.Close(websocket.StatusNormalClosure, "normal closure") }() - - if strings.HasSuffix(remoteDir, string(filepath.Separator)) { - remoteDir += filepath.Base(local) - } - - execer := wsep.RemoteExecer(conn) - cmd := fmt.Sprintf(`[ -d %s ] && cat > %s/%s || cat > %s`, remoteDir, remoteDir, filepath.Base(local), remoteDir) - process, err := execer.Start(ctx, wsep.Command{ - Command: "sh", - Args: []string{"-c", cmd}, - Stdin: true, - }) - if err != nil { - return xerrors.Errorf("start sync command: %w", err) - } - - sourceFile, err := os.Open(local) - if err != nil { - return xerrors.Errorf("open source file: %w", err) - } - - go func() { _, _ = io.Copy(ioutil.Discard, process.Stdout()) }() - go func() { _, _ = io.Copy(ioutil.Discard, process.Stderr()) }() - go func() { - stdin := process.Stdin() - defer stdin.Close() - _, _ = io.Copy(stdin, sourceFile) - }() - - if err := process.Wait(); err != nil { - return xerrors.Errorf("copy process: %w", err) - } - return nil -} diff --git a/internal/sync/sync.go b/internal/sync/sync.go deleted file mode 100644 index dd90cf7a..00000000 --- a/internal/sync/sync.go +++ /dev/null @@ -1,393 +0,0 @@ -// Package sync contains logic for establishing a file sync between a local machine and a Coder workspace. -package sync - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "io/ioutil" - "os" - "os/exec" - "path" - "path/filepath" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - "github.com/rjeczalik/notify" - "golang.org/x/sync/semaphore" - "golang.org/x/xerrors" - - "cdr.dev/wsep" - - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/coderutil" - "cdr.dev/coder-cli/pkg/clog" -) - -// Sync runs a live sync daemon. -type Sync struct { - // Init sets whether the sync will do the initial init and then return fast. - Init bool - // LocalDir is an absolute path. - LocalDir string - // RemoteDir is an absolute path. - RemoteDir string - // DisableMetrics disables activity metric pushing. - DisableMetrics bool - - Workspace coder.Workspace - Client coder.Client - OutW io.Writer - ErrW io.Writer - InputReader io.Reader - IsInteractiveOutput bool -} - -// See https://lxadm.com/Rsync_exit_codes#List_of_standard_rsync_exit_codes. -const ( - rsyncExitCodeIncompat = 2 - rsyncExitCodeDataStream = 12 -) - -func (s Sync) syncPaths(delete bool, local, remote string) error { - self := os.Args[0] - - args := []string{"-zz", - "-a", - "--delete", - "-e", self + " sh", local, s.Workspace.Name + ":" + remote, - } - if delete { - args = append([]string{"--delete"}, args...) - } - if os.Getenv("DEBUG_RSYNC") != "" { - args = append([]string{"--progress"}, args...) - } - - // See https://unix.stackexchange.com/questions/188737/does-compression-option-z-with-rsync-speed-up-backup - // on compression level. - // (AB): compression sped up the initial sync of the enterprise repo by 30%, leading me to believe it's - // good in general for codebases. - cmd := exec.Command("rsync", args...) - cmd.Stdout = s.OutW - cmd.Stderr = ioutil.Discard - cmd.Stdin = s.InputReader - - if err := cmd.Run(); err != nil { - if exitError, ok := err.(*exec.ExitError); ok { - switch { - case exitError.ExitCode() == rsyncExitCodeIncompat: - return xerrors.Errorf("no compatible rsync on remote machine: rsync: %w", err) - case exitError.ExitCode() == rsyncExitCodeDataStream: - return xerrors.Errorf("protocol datastream error or no remote rsync found: %w", err) - } - return xerrors.Errorf("rsync: %w", err) - } - return xerrors.Errorf("rsync: %w", err) - } - return nil -} - -func (s Sync) remoteCmd(ctx context.Context, prog string, args ...string) error { - conn, err := coderutil.DialWorkspaceWsep(ctx, s.Client, &s.Workspace) - if err != nil { - return xerrors.Errorf("dial executor: %w", err) - } - defer func() { _ = conn.Close(websocket.CloseNormalClosure, "") }() // Best effort. - - execer := wsep.RemoteExecer(conn) - process, err := execer.Start(ctx, wsep.Command{ - Command: prog, - Args: args, - }) - if err != nil { - return xerrors.Errorf("exec remote process: %w", err) - } - // NOTE: If the copy routine fail, it will result in `process.Wait` to unblock and report an error. - go func() { _, _ = io.Copy(s.OutW, process.Stdout()) }() // Best effort. - go func() { _, _ = io.Copy(s.ErrW, process.Stderr()) }() // Best effort. - - if err := process.Wait(); err != nil { - if code, ok := err.(wsep.ExitError); ok { - return xerrors.Errorf("%s exit status: %d", prog, code) - } - return xerrors.Errorf("execution failure: %w", err) - } - - return nil -} - -// initSync performs the initial synchronization of the directory. -func (s Sync) initSync() error { - clog.LogInfo(fmt.Sprintf("doing initial sync (%s -> %s)", s.LocalDir, s.RemoteDir)) - - start := time.Now() - // Delete old files on initial sync (e.g git checkout). - // Add the "/." to the local directory so rsync doesn't try to place the directory - // into the remote dir. - if err := s.syncPaths(true, s.LocalDir+"/.", s.RemoteDir); err != nil { - return err - } - clog.LogSuccess( - fmt.Sprintf("finished initial sync (%s)", time.Since(start).Truncate(time.Millisecond)), - ) - return nil -} - -func (s Sync) convertPath(local string) string { - relLocalPath, err := filepath.Rel(s.LocalDir, local) - if err != nil { - panic(err) - } - return filepath.Join(s.RemoteDir, relLocalPath) -} - -func (s Sync) handleCreate(localPath string) error { - target := s.convertPath(localPath) - - if err := s.syncPaths(false, localPath, target); err != nil { - // File was quickly deleted. - if _, e1 := os.Stat(localPath); os.IsNotExist(e1) { // NOTE: Discard any other stat error and just expose the syncPath one. - return nil - } - return err - } - return nil -} - -func (s Sync) handleDelete(localPath string) error { - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - return s.remoteCmd(ctx, "rm", "-rf", s.convertPath(localPath)) -} - -func (s Sync) handleRename(localPath string) error { - // The rename operation is sent in two events, one - // for the old (gone) file and one for the new file. - // Catching both would require complex state. - // Instead, we turn it into a Create or Delete based - // on file existence. - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return s.handleDelete(localPath) - } - return err - } - if info.IsDir() { - // Without this, the directory will be created as a subdirectory. - localPath += "/." - } - return s.handleCreate(localPath) -} - -func (s Sync) work(ev timedEvent) { - var ( - localPath = ev.Path() - err error - ) - switch ev.Event() { - case notify.Write, notify.Create: - err = s.handleCreate(localPath) - case notify.Rename: - err = s.handleRename(localPath) - case notify.Remove: - err = s.handleDelete(localPath) - default: - clog.LogInfo(fmt.Sprintf("unhandled event %+v %s", ev.Event(), ev.Path())) - } - - log := fmt.Sprintf("%v %s (%s)", - ev.Event(), filepath.Base(localPath), time.Since(ev.CreatedAt).Truncate(time.Millisecond*10), - ) - if err != nil { - clog.Log(clog.Error(fmt.Sprintf("%s: %s", log, err))) - } else { - clog.LogSuccess(log) - } -} - -// 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. -func (s Sync) workEventGroup(evs []timedEvent) { - cache := eventCache{} - for _, ev := range evs { - cache.Add(ev) - } - - // We want to process events concurrently but safely for speed. - // Because the event cache prevents duplicate events for the same file, race conditions of that type - // are impossible. - // What is possible is a dependency on a previous Rename or Create. For example, if a directory is renamed - // and then a file is moved to it. AFAIK this dependecy only exists with Directories. - // So, we sequentially process the list of directory Renames and Creates, and then concurrently - // perform all Writes. - for _, ev := range cache.SequentialEvents() { - s.work(ev) - } - - sem := semaphore.NewWeighted(8) - - var wg sync.WaitGroup - for _, ev := range cache.ConcurrentEvents() { - setConsoleTitle(fmtUpdateTitle(ev.Path()), s.IsInteractiveOutput) - - wg.Add(1) - // TODO: Document why this error is discarded. See https://github.com/cdr/coder-cli/issues/122 for reference. - _ = sem.Acquire(context.Background(), 1) - - ev := ev // Copy the event in the scope to make sure the go routine use the proper value. - go func() { - defer sem.Release(1) - defer wg.Done() - s.work(ev) - }() - } - - wg.Wait() -} - -const ( - // maxinflightInotify sets the maximum number of inotifies before the - // sync just restarts. Syncing a large amount of small files (e.g .git - // or node_modules) is impossible to do performantly with individual - // rsyncs. - maxInflightInotify = 8 - maxEventDelay = 7 * time.Second - // maxAcceptableDispatch is the maximum amount of time before an event - // should begin its journey to the server. This sets a lower bound for - // perceivable latency, but the higher it is, the better the - // optimization. - maxAcceptableDispatch = 50 * time.Millisecond -) - -// Version returns remote protocol version as a string. -// Or, an error if one exists. -func (s Sync) Version() (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - conn, err := coderutil.DialWorkspaceWsep(ctx, s.Client, &s.Workspace) - if err != nil { - return "", xerrors.Errorf("dial workspace executor: %w", err) - } - defer func() { _ = conn.Close(websocket.CloseNormalClosure, "") }() // Best effort. - - execer := wsep.RemoteExecer(conn) - process, err := execer.Start(ctx, wsep.Command{ - Command: "rsync", - Args: []string{"--version"}, - }) - if err != nil { - return "", err - } - buf := &bytes.Buffer{} - _, _ = io.Copy(buf, process.Stdout()) // Ignore error, if any, it would be handled by the process.Wait return. - - if err := process.Wait(); err != nil { - return "", err - } - - firstLine, err := buf.ReadString('\n') - if err != nil { - return "", err - } - - versionString := strings.Split(firstLine, "protocol version ") - - return versionString[1], nil -} - -// Run starts the sync synchronously. -// Use this command to debug what wasn't sync'd correctly: -// rsync -e "coder sh" -nicr ~/Projects/cdr/coder-cli/. ammar:/home/coder/coder-cli/. -func (s Sync) Run() error { - events := make(chan notify.EventInfo, maxInflightInotify) - // Set up a recursive watch. - // We do this before the initial sync so we can capture any changes - // that may have happened during sync. - if err := notify.Watch(path.Join(s.LocalDir, "..."), events, notify.All); err != nil { - return xerrors.Errorf("create watch: %w", err) - } - defer notify.Stop(events) - - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - - if err := s.remoteCmd(ctx, "mkdir", "-p", s.RemoteDir); err != nil { - return xerrors.Errorf("create remote directory: %w", err) - } - - ap := activity.NewPusher(s.Client, s.Workspace.ID, activityName) - ap.Push(ctx) - - setConsoleTitle("⏳ syncing project", s.IsInteractiveOutput) - if err := s.initSync(); err != nil { - return err - } - - if s.Init { - return nil - } - - clog.LogInfo(fmt.Sprintf("watching %s for changes", s.LocalDir)) - - var droppedEvents uint64 - // Timed events lets us track how long each individual file takes to - // update. - timedEvents := make(chan timedEvent, cap(events)) - go func() { - defer close(timedEvents) - for event := range events { - select { - case timedEvents <- timedEvent{ - CreatedAt: time.Now(), - EventInfo: event, - }: - default: - if atomic.AddUint64(&droppedEvents, 1) == 1 { - clog.LogInfo("dropped event, sync should restart soon") - } - } - } - }() - - var eventGroup []timedEvent - - dispatchEventGroup := time.NewTicker(maxAcceptableDispatch) - defer dispatchEventGroup.Stop() - for { - const watchingFilesystemTitle = "🛰 watching filesystem" - setConsoleTitle(watchingFilesystemTitle, s.IsInteractiveOutput) - - select { - case ev := <-timedEvents: - if atomic.LoadUint64(&droppedEvents) > 0 { - return ErrRestartSync - } - eventGroup = append(eventGroup, ev) - case <-dispatchEventGroup.C: - if len(eventGroup) == 0 { - continue - } - // We're too backlogged and should restart the sync. - if time.Since(eventGroup[0].CreatedAt) > maxEventDelay { - return ErrRestartSync - } - s.workEventGroup(eventGroup) - eventGroup = eventGroup[:0] - ap.Push(context.TODO()) - } - } -} - -const activityName = "sync" diff --git a/internal/sync/title.go b/internal/sync/title.go deleted file mode 100644 index ae7630d8..00000000 --- a/internal/sync/title.go +++ /dev/null @@ -1,17 +0,0 @@ -package sync - -import ( - "fmt" - "path/filepath" -) - -func setConsoleTitle(title string, isInteractiveOutput bool) { - if !isInteractiveOutput { - return - } - fmt.Printf("\033]0;%s\007", title) -} - -func fmtUpdateTitle(path string) string { - return "🚀 updating " + filepath.Base(path) -} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index ce1d5de9..00000000 --- a/internal/version/version.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package version contains the compile-time injected version string and -// related utiliy methods. -package version - -import ( - "strings" -) - -// Version is populated at compile-time with the current coder-cli version. -var Version string = "unknown" - -// VersionsMatch compares the given APIVersion to the compile-time injected coder-cli version. -func VersionsMatch(apiVersion string) bool { - withoutPatchRelease := strings.Split(Version, ".") - if len(withoutPatchRelease) < 3 { - return false - } - majorMinor := strings.Join(withoutPatchRelease[:2], ".") - return strings.HasPrefix(strings.TrimPrefix(apiVersion, "v"), strings.TrimPrefix(majorMinor, "v")) -} diff --git a/internal/version/version_test.go b/internal/version/version_test.go deleted file mode 100644 index e51b64dd..00000000 --- a/internal/version/version_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package version - -import ( - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" -) - -func TestVersion(t *testing.T) { - Version = "1.12.1" - match := VersionsMatch("1.12.2") - assert.True(t, "versions match", match) - - Version = "v1.14.1" - match = VersionsMatch("1.15.2") - assert.True(t, "versions do not match", !match) - - Version = "v1.15.4" - match = VersionsMatch("1.15.2") - assert.True(t, "versions do match", match) - - Version = "1.15.4" - match = VersionsMatch("v1.15.2") - assert.True(t, "versions do match", match) - - Version = "1.15.4" - match = VersionsMatch("v2.15.2") - assert.True(t, "versions do not match", !match) - - Version = "1.12.2+cli.rc1" - match = VersionsMatch("v1.12.9") - assert.True(t, "versions do match", match) -} diff --git a/internal/x/xcobra/cobra.go b/internal/x/xcobra/cobra.go deleted file mode 100644 index 7ddfd9e2..00000000 --- a/internal/x/xcobra/cobra.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package xcobra wraps the cobra package to provide richer functionality. -package xcobra - -import ( - "fmt" - - "github.com/spf13/cobra" - - "cdr.dev/coder-cli/pkg/clog" -) - -// ExactArgs returns an error if there are not exactly n args. -func ExactArgs(n int) cobra.PositionalArgs { - return func(cmd *cobra.Command, args []string) error { - if len(args) != n { - return clog.Error( - fmt.Sprintf("accepts %d arg(s), received %d", n, len(args)), - clog.Bold("usage: ")+cmd.UseLine(), - clog.BlankLine, - clog.Tipf("use \"--help\" for more info"), - ) - } - return nil - } -} diff --git a/internal/x/xsync/doc.go b/internal/x/xsync/doc.go deleted file mode 100644 index fb23bcce..00000000 --- a/internal/x/xsync/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package xsync provides utilities for concurrency. -package xsync diff --git a/internal/x/xsync/syncwriter.go b/internal/x/xsync/syncwriter.go deleted file mode 100644 index 3a26f262..00000000 --- a/internal/x/xsync/syncwriter.go +++ /dev/null @@ -1,24 +0,0 @@ -package xsync - -import ( - "io" - "sync" -) - -// Writer synchronizes concurrent writes to an underlying writer. -func Writer(w io.Writer) io.Writer { - return &writer{ - w: w, - } -} - -type writer struct { - mu sync.Mutex - w io.Writer -} - -func (sw *writer) Write(b []byte) (int, error) { - sw.mu.Lock() - defer sw.mu.Unlock() - return sw.w.Write(b) -} diff --git a/internal/x/xterminal/doc.go b/internal/x/xterminal/doc.go deleted file mode 100644 index 21e0ae0e..00000000 --- a/internal/x/xterminal/doc.go +++ /dev/null @@ -1,13 +0,0 @@ -// Package xterminal provides functions to change termios or console attributes -// and restore them later on. It supports Unix and Windows. -// -// This does the same thing as x/crypto/ssh/terminal on Linux. On Windows, it -// sets the same console modes as the terminal package but also sets -// `ENABLE_VIRTUAL_TERMINAL_INPUT` and `ENABLE_VIRTUAL_TERMINAL_PROCESSING` to -// allow for VT100 sequences in the console. This is important, otherwise Linux -// apps (with colors or ncurses) that are run through SSH or wsep get -// garbled in a Windows console. -// -// More details can be found out about Windows console modes here: -// https://docs.microsoft.com/en-us/windows/console/setconsolemode -package xterminal diff --git a/internal/x/xterminal/terminal.go b/internal/x/xterminal/terminal.go deleted file mode 100644 index 49ae7cba..00000000 --- a/internal/x/xterminal/terminal.go +++ /dev/null @@ -1,24 +0,0 @@ -// +build !windows - -package xterminal - -import ( - "golang.org/x/term" -) - -// State differs per-platform. -type State struct { - s *term.State -} - -// MakeOutputRaw does nothing on non-Windows platforms. -func MakeOutputRaw(fd uintptr) (*State, error) { return nil, nil } - -// Restore terminal back to original state. -func Restore(fd uintptr, state *State) error { - if state == nil { - return nil - } - - return term.Restore(int(fd), state.s) -} diff --git a/internal/x/xterminal/terminal_windows.go b/internal/x/xterminal/terminal_windows.go deleted file mode 100644 index a016e5a7..00000000 --- a/internal/x/xterminal/terminal_windows.go +++ /dev/null @@ -1,48 +0,0 @@ -// +build windows - -package xterminal - -import ( - "golang.org/x/sys/windows" -) - -// State differs per-platform. -type State struct { - mode uint32 -} - -// makeRaw sets the terminal in raw mode and returns the previous state so it can be restored. -func makeRaw(handle windows.Handle, input bool) (uint32, error) { - var prevState uint32 - if err := windows.GetConsoleMode(handle, &prevState); err != nil { - return 0, err - } - - var raw uint32 - if input { - raw = prevState &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) - raw |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT - } else { - raw = prevState | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING - } - - if err := windows.SetConsoleMode(handle, raw); err != nil { - return 0, err - } - return prevState, nil -} - -// MakeOutputRaw sets an output terminal to raw and enables VT100 processing. -func MakeOutputRaw(handle uintptr) (*State, error) { - prevState, err := makeRaw(windows.Handle(handle), false) - if err != nil { - return nil, err - } - - return &State{mode: prevState}, nil -} - -// Restore terminal back to original state. -func Restore(handle uintptr, state *State) error { - return windows.SetConsoleMode(windows.Handle(handle), state.mode) -} diff --git a/pkg/clog/clog.go b/pkg/clog/clog.go deleted file mode 100644 index 0a523e1f..00000000 --- a/pkg/clog/clog.go +++ /dev/null @@ -1,136 +0,0 @@ -package clog - -import ( - "errors" - "fmt" - "io" - "os" - "strings" - - "github.com/fatih/color" - "golang.org/x/xerrors" -) - -var writer io.Writer = os.Stderr - -// SetOutput sets the package-level writer target for log functions. -func SetOutput(w io.Writer) { - writer = w -} - -// CLIMessage provides a human-readable message for CLI errors and messages. -type CLIMessage struct { - Level string - Color color.Attribute - Header string - Lines []string -} - -// CLIError wraps a CLIMessage and allows consumers to treat it as a normal error. -type CLIError struct { - CLIMessage - error -} - -// String formats the CLI message for consumption by a human. -func (m CLIMessage) String() string { - var str strings.Builder - str.WriteString(fmt.Sprintf("%s: %s\n", - color.New(m.Color).Sprint(m.Level), - color.New(color.Bold).Sprint(m.Header)), - ) - for _, line := range m.Lines { - str.WriteString(fmt.Sprintf(" %s %s\n", color.New(m.Color).Sprint("|"), line)) - } - return str.String() -} - -// Log logs the given error to stderr, defaulting to "fatal" if the error is not a CLIError. -// If the error is a CLIError, the plain error chain is ignored and the CLIError -// is logged on its own. -func Log(err error) { - var cliErr CLIError - if !xerrors.As(err, &cliErr) { - cliErr = Fatal(err.Error()) - } - fmt.Fprintln(writer, cliErr.String()) -} - -// LogInfo prints the given info message to stderr. -func LogInfo(header string, lines ...string) { - fmt.Fprint(writer, CLIMessage{ - Level: "info", - Color: color.FgBlue, - Header: header, - Lines: lines, - }.String()) -} - -// LogSuccess prints the given info message to stderr. -func LogSuccess(header string, lines ...string) { - fmt.Fprint(writer, CLIMessage{ - Level: "success", - Color: color.FgGreen, - Header: header, - Lines: lines, - }.String()) -} - -// LogWarn prints the given warn message to stderr. -func LogWarn(header string, lines ...string) { - fmt.Fprint(writer, CLIMessage{ - Level: "warning", - Color: color.FgYellow, - Header: header, - Lines: lines, - }.String()) -} - -// Error creates an error with the level "error". -func Error(header string, lines ...string) CLIError { - return CLIError{ - CLIMessage: CLIMessage{ - Color: color.FgRed, - Level: "error", - Header: header, - Lines: lines, - }, - error: errors.New(header), - } -} - -// Fatal creates an error with the level "fatal". -func Fatal(header string, lines ...string) CLIError { - return CLIError{ - CLIMessage: CLIMessage{ - Color: color.FgRed, - Level: "fatal", - Header: header, - Lines: lines, - }, - error: errors.New(header), - } -} - -// Bold provides a convenience wrapper around color.New for brevity when logging. -func Bold(a string) string { - return color.New(color.Bold).Sprint(a) -} - -// Tipf formats according to the given format specifier and prepends a bolded "tip: " header. -func Tipf(format string, a ...interface{}) string { - return fmt.Sprintf("%s %s", Bold("tip:"), fmt.Sprintf(format, a...)) -} - -// Hintf formats according to the given format specifier and prepends a bolded "hint: " header. -func Hintf(format string, a ...interface{}) string { - return fmt.Sprintf("%s %s", Bold("hint:"), fmt.Sprintf(format, a...)) -} - -// Causef formats according to the given format specifier and prepends a bolded "cause: " header. -func Causef(format string, a ...interface{}) string { - return fmt.Sprintf("%s %s", Bold("cause:"), fmt.Sprintf(format, a...)) -} - -// BlankLine is an empty string meant to be used in CLIMessage and CLIError construction. -const BlankLine = "" diff --git a/pkg/clog/clog_test.go b/pkg/clog/clog_test.go deleted file mode 100644 index 51eab07e..00000000 --- a/pkg/clog/clog_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package clog - -import ( - "bytes" - "fmt" - "io/ioutil" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - "golang.org/x/xerrors" -) - -func TestError(t *testing.T) { - t.Run("oneline", func(t *testing.T) { - var mockErr error = Error("fake error") - mockErr = xerrors.Errorf("wrap 1: %w", mockErr) - mockErr = fmt.Errorf("wrap 2: %w", mockErr) - - var buf bytes.Buffer - //! clearly not concurrent safe - SetOutput(&buf) - - Log(mockErr) - - output, err := ioutil.ReadAll(&buf) - assert.Success(t, "read all stderr output", err) - - assert.Equal(t, "output is as expected", "error: fake error\n\n", string(output)) - }) - - t.Run("plain-error", func(t *testing.T) { - mockErr := xerrors.Errorf("base error") - mockErr = fmt.Errorf("wrap 1: %w", mockErr) - - var buf bytes.Buffer - //! clearly not concurrent safe - SetOutput(&buf) - - Log(mockErr) - - output, err := ioutil.ReadAll(&buf) - assert.Success(t, "read all stderr output", err) - - assert.Equal(t, "output is as expected", "fatal: wrap 1: base error\n\n", string(output)) - }) - - t.Run("message", func(t *testing.T) { - for _, f := range []struct { - f func(string, ...string) - level string - }{{LogInfo, "info"}, {LogSuccess, "success"}, {LogWarn, "warning"}} { - var buf bytes.Buffer - //! clearly not concurrent safe - SetOutput(&buf) - - f.f("testing", Hintf("maybe do %q", "this"), BlankLine, Causef("what happened was %q", "this")) - - output, err := ioutil.ReadAll(&buf) - assert.Success(t, "read all stderr output", err) - - assert.Equal(t, "output is as expected", f.level+": testing\n | hint: maybe do \"this\"\n | \n | cause: what happened was \"this\"\n", string(output)) - } - }) - - t.Run("multi-line", func(t *testing.T) { - var mockErr error = Error("fake header", "next line", BlankLine, Tipf("content of fake tip")) - mockErr = xerrors.Errorf("wrap 1: %w", mockErr) - mockErr = fmt.Errorf("wrap 1: %w", mockErr) - - var buf bytes.Buffer - //! clearly not concurrent safe - SetOutput(&buf) - - Log(mockErr) - - output, err := ioutil.ReadAll(&buf) - assert.Success(t, "read all stderr output", err) - - assert.Equal(t, - "output is as expected", - "error: fake header\n | next line\n | \n | tip: content of fake tip\n\n", - string(output), - ) - }) -} diff --git a/pkg/clog/doc.go b/pkg/clog/doc.go deleted file mode 100644 index 9e5717bb..00000000 --- a/pkg/clog/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package clog provides rich error types and logging helpers for coder-cli. -// -// clog encourages returning error types rather than -// logging them and failing with os.Exit as they happen. -// Error, Fatal, and Warn allow downstream functions to return errors with rich formatting information -// while preserving the original, single-line error chain. -package clog diff --git a/pkg/clog/errgroup.go b/pkg/clog/errgroup.go deleted file mode 100644 index a96d6449..00000000 --- a/pkg/clog/errgroup.go +++ /dev/null @@ -1,58 +0,0 @@ -package clog - -import ( - "fmt" - "sync/atomic" - - "golang.org/x/sync/errgroup" - "golang.org/x/xerrors" -) - -// ErrGroup wraps the /x/sync/errgroup.(Group) and adds clog logging and rich error propagation. -// -// Take for example, a case in which we are concurrently stopping a slice of workspaces. -// In this case, we want to log errors as they happen, not pass them through the callstack as errors. -// When the operations complete, we want to log how many, if any, failed. The caller is still expected -// to handle success and info logging. -type ErrGroup interface { - Go(f func() error) - Wait() error -} - -type group struct { - egroup errgroup.Group - failures int32 -} - -// LoggedErrGroup gives an error group with error logging and error propagation handled automatically. -func LoggedErrGroup() ErrGroup { - return &group{ - egroup: errgroup.Group{}, - failures: 0, - } -} - -func (g *group) Go(f func() error) { - g.egroup.Go(func() error { - if err := f(); err != nil { - atomic.AddInt32(&g.failures, 1) - Log(err) - - // this error does not matter because we discard it in Wait. - return xerrors.New("") - } - return nil - }) -} - -func (g *group) Wait() error { - _ = g.egroup.Wait() // ignore this error because we are already tracking failures manually - if g.failures == 0 { - return nil - } - failureWord := "failure" - if g.failures > 1 { - failureWord += "s" - } - return Fatal(fmt.Sprintf("%d %s emitted", g.failures, failureWord)) -} diff --git a/pkg/clog/errgroup_test.go b/pkg/clog/errgroup_test.go deleted file mode 100644 index b632921d..00000000 --- a/pkg/clog/errgroup_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package clog - -import ( - "bytes" - "errors" - "strings" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/internal/x/xsync" -) - -func TestErrGroup(t *testing.T) { - t.Run("success", func(t *testing.T) { - egroup := LoggedErrGroup() - - var buf bytes.Buffer - SetOutput(xsync.Writer(&buf)) - - egroup.Go(func() error { return nil }) - egroup.Go(func() error { return nil }) - egroup.Go(func() error { return nil }) - - err := egroup.Wait() - assert.Success(t, "error group wait", err) - assert.Equal(t, "empty log buffer", "", buf.String()) - }) - t.Run("failure_count", func(t *testing.T) { - egroup := LoggedErrGroup() - - var buf bytes.Buffer - SetOutput(xsync.Writer(&buf)) - - egroup.Go(func() error { return errors.New("whoops") }) - egroup.Go(func() error { return Error("rich error", "second line") }) - - err := egroup.Wait() - assert.ErrorContains(t, "error group wait", err, "2 failures emitted") - assert.True(t, "log buf contains", strings.Contains(buf.String(), "fatal: whoops\n\n")) - assert.True(t, "log buf contains", strings.Contains(buf.String(), "error: rich error\n | second line\n\n")) - }) -} diff --git a/pkg/proto/doc.go b/pkg/proto/doc.go deleted file mode 100644 index 65f4d1e5..00000000 --- a/pkg/proto/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package proto contains shared messages for webrtc handshakes. -package proto diff --git a/pkg/tablewriter/doc.go b/pkg/tablewriter/doc.go deleted file mode 100644 index 366a7b9e..00000000 --- a/pkg/tablewriter/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package tablewriter provides helpers for printing human-readable tabular data from slices of structs. -package tablewriter diff --git a/pkg/tablewriter/table_output.golden b/pkg/tablewriter/table_output.golden deleted file mode 100755 index dfe3299c..00000000 --- a/pkg/tablewriter/table_output.golden +++ /dev/null @@ -1,3 +0,0 @@ -Name birthday month first_nested second_nested Age -Tom 12 234-0934 2340-234234 28.12 -Jerry 3 aflfafe-afjlk falj-fjlkjlkadf 36.22 diff --git a/pkg/tablewriter/tablewriter.go b/pkg/tablewriter/tablewriter.go deleted file mode 100644 index 9f12becd..00000000 --- a/pkg/tablewriter/tablewriter.go +++ /dev/null @@ -1,96 +0,0 @@ -package tablewriter - -import ( - "fmt" - "io" - "reflect" - "strings" - "text/tabwriter" -) - -const structFieldTagKey = "table" - -// StructValues tab delimits the values of a given struct. -// -// Tag a field `table:"-"` to hide it from output. -// Tag a field `table:"_"` to flatten its subfields. -func StructValues(data interface{}) string { - v := reflect.ValueOf(data) - s := &strings.Builder{} - for i := 0; i < v.NumField(); i++ { - if shouldHideField(v.Type().Field(i)) { - continue - } - if shouldFlatten(v.Type().Field(i)) { - fmt.Fprintf(s, "%v", StructValues(v.Field(i).Interface())) - continue - } - fmt.Fprintf(s, "%v\t", v.Field(i).Interface()) - } - return s.String() -} - -// StructFieldNames tab delimits the field names of a given struct. -// -// Tag a field `table:"-"` to hide it from output. -// Tag a field `table:"_"` to flatten its subfields. -func StructFieldNames(data interface{}) string { - v := reflect.ValueOf(data) - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - s := &strings.Builder{} - for i := 0; i < v.NumField(); i++ { - field := v.Type().Field(i) - if shouldHideField(field) { - continue - } - if shouldFlatten(field) { - fmt.Fprintf(s, "%s", StructFieldNames(reflect.New(field.Type).Interface())) - continue - } - fmt.Fprintf(s, "%s\t", fieldName(field)) - } - return s.String() -} - -// WriteTable writes the given list elements to stdout in a human readable -// tabular format. Headers abide by the `table` struct tag. -// -// `table:"-"` omits the field and no tag defaults to the Go identifier. -// `table:"_"` flattens a fields subfields. -func WriteTable(writer io.Writer, length int, each func(i int) interface{}) error { - if length < 1 { - return nil - } - w := tabwriter.NewWriter(writer, 0, 0, 4, ' ', 0) - defer func() { _ = w.Flush() }() // Best effort. - for ix := 0; ix < length; ix++ { - item := each(ix) - if ix == 0 { - if _, err := fmt.Fprintln(w, StructFieldNames(item)); err != nil { - return err - } - } - if _, err := fmt.Fprintln(w, StructValues(item)); err != nil { - return err - } - } - return nil -} - -func fieldName(f reflect.StructField) string { - custom, ok := f.Tag.Lookup(structFieldTagKey) - if ok { - return custom - } - return f.Name -} - -func shouldFlatten(f reflect.StructField) bool { - return f.Tag.Get(structFieldTagKey) == "_" -} - -func shouldHideField(f reflect.StructField) bool { - return f.Tag.Get(structFieldTagKey) == "-" -} diff --git a/pkg/tablewriter/tablewriter_test.go b/pkg/tablewriter/tablewriter_test.go deleted file mode 100644 index e611e52c..00000000 --- a/pkg/tablewriter/tablewriter_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package tablewriter - -import ( - "bytes" - "flag" - "io/ioutil" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" -) - -var write = flag.Bool("write", false, "write to the golden files") - -func TestTableWriter(t *testing.T) { - type NestedRow struct { - NestedOne string `table:"first_nested"` - NestedTwo string `table:"second_nested"` - } - - type Row struct { - ID string `table:"-"` - Name string - BirthdayMonth int `table:"birthday month"` - Nested NestedRow `table:"_"` - Age float32 - } - - items := []Row{ - { - ID: "13123lkjqlkj-2f323l--f23f", - Name: "Tom", - BirthdayMonth: 12, - Age: 28.12, - Nested: NestedRow{ - NestedOne: "234-0934", - NestedTwo: "2340-234234", - }, - }, - { - ID: "afwaflkj23kl-2f323l--f23f", - Name: "Jerry", - BirthdayMonth: 3, - Age: 36.22, - Nested: NestedRow{ - NestedOne: "aflfafe-afjlk", - NestedTwo: "falj-fjlkjlkadf", - }, - }, - } - - buf := bytes.NewBuffer(nil) - err := WriteTable(buf, len(items), func(i int) interface{} { return items[i] }) - assert.Success(t, "write table", err) - - assertGolden(t, "table_output.golden", buf.Bytes()) -} - -func assertGolden(t *testing.T, path string, output []byte) { - if *write { - err := ioutil.WriteFile(path, output, 0777) - assert.Success(t, "write file", err) - return - } - goldenContent, err := ioutil.ReadFile(path) - assert.Success(t, "read golden file", err) - assert.Equal(t, "golden content matches", string(goldenContent), string(output)) -} diff --git a/pkg/tcli/doc.go b/pkg/tcli/doc.go deleted file mode 100644 index 561dc480..00000000 --- a/pkg/tcli/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// 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/pkg/tcli/tcli.go b/pkg/tcli/tcli.go deleted file mode 100644 index b09f4885..00000000 --- a/pkg/tcli/tcli.go +++ /dev/null @@ -1,352 +0,0 @@ -package tcli - -import ( - "bytes" - "context" - "encoding/json" - "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, - "--network", "host", - "--rm", "-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( - "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( - "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 - ) - if a.cmd == nil { - slogtest.Fatal(t, "test failed to initialize: no command specified") - } - - a.cmd.Stdout = &stdout - a.cmd.Stderr = &stderr - - start := time.Now() - err := a.cmd.Run() - result.Duration = time.Since(start) - - if err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - result.ExitCode = exitErr.ExitCode() - } else { - 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, "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()), - ) - } - } -} - -// StdoutJSONUnmarshal attempts to unmarshal stdout into the given target. -func StdoutJSONUnmarshal(target interface{}) Assertion { - return func(t *testing.T, r *CommandResult) { - slog.Helper() - err := json.Unmarshal(r.Stdout, target) - assert.Success(t, "stdout json unmarshals", err) - } -} - -// StderrJSONUnmarshal attempts to unmarshal stderr into the given target. -func StderrJSONUnmarshal(target interface{}) Assertion { - return func(t *testing.T, r *CommandResult) { - slog.Helper() - err := json.Unmarshal(r.Stdout, target) - assert.Success(t, "stderr json unmarshals", err) - } -} diff --git a/pkg/tcli/tcli_test.go b/pkg/tcli/tcli_test.go deleted file mode 100644 index 178e702c..00000000 --- a/pkg/tcli/tcli_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package tcli_test - -import ( - "context" - "os" - "os/exec" - "strings" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - - "cdr.dev/coder-cli/pkg/tcli" -) - -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/wsnet/cache.go b/wsnet/cache.go deleted file mode 100644 index b16950ca..00000000 --- a/wsnet/cache.go +++ /dev/null @@ -1,161 +0,0 @@ -package wsnet - -import ( - "context" - "errors" - "sync" - "time" - - "github.com/pion/webrtc/v3" - "golang.org/x/sync/singleflight" -) - -// DialCache constructs a new DialerCache. -// The cache clears connections that: -// 1. Are older than the TTL and have no active user-created connections. -// 2. Have been closed. -func DialCache(ttl time.Duration) *DialerCache { - dc := &DialerCache{ - ttl: ttl, - closed: make(chan struct{}), - flightGroup: &singleflight.Group{}, - mut: sync.RWMutex{}, - dialers: make(map[string]*Dialer), - atime: make(map[string]time.Time), - } - go dc.init() - return dc -} - -type DialerCache struct { - ttl time.Duration - flightGroup *singleflight.Group - closed chan struct{} - mut sync.RWMutex - - // Key is the "key" of a dialer, which is usually the workspace ID. - dialers map[string]*Dialer - atime map[string]time.Time -} - -// init starts the ticker for evicting connections. -func (d *DialerCache) init() { - ticker := time.NewTicker(time.Second * 5) - defer ticker.Stop() - for { - select { - case <-d.closed: - return - case <-ticker.C: - d.evict() - } - } -} - -// evict removes lost/broken/expired connections from the cache. -func (d *DialerCache) evict() { - var wg sync.WaitGroup - d.mut.RLock() - for key, dialer := range d.dialers { - wg.Add(1) - key := key - dialer := dialer - go func() { - defer wg.Done() - - // If we're no longer signaling, the connection is pending close. - evict := dialer.rtc.SignalingState() == webrtc.SignalingStateClosed - if dialer.activeConnections() == 0 && time.Since(d.atime[key]) >= d.ttl { - evict = true - } else { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - err := dialer.Ping(ctx) - if err != nil { - evict = true - } - } - - if !evict { - return - } - - _ = dialer.Close() - // Ensure after Ping and potential delays that we're still testing against - // the proper dialer. - if dialer != d.dialers[key] { - return - } - - d.mut.Lock() - defer d.mut.Unlock() - delete(d.atime, key) - delete(d.dialers, key) - }() - } - d.mut.RUnlock() - wg.Wait() -} - -// Dial returns a Dialer from the cache if one exists with the key provided, -// or dials a new connection using the dialerFunc. -// The bool returns whether the connection was found in the cache or not. -func (d *DialerCache) Dial(ctx context.Context, key string, dialerFunc func() (*Dialer, error)) (*Dialer, bool, error) { - select { - case <-d.closed: - return nil, false, errors.New("cache closed") - default: - } - - d.mut.RLock() - dialer, ok := d.dialers[key] - d.mut.RUnlock() - if ok { - d.mut.Lock() - d.atime[key] = time.Now() - d.mut.Unlock() - - // The connection is pending close here... - if dialer.rtc.SignalingState() != webrtc.SignalingStateClosed { - return dialer, true, nil - } - } - - rawDialer, err, _ := d.flightGroup.Do(key, func() (interface{}, error) { - dialer, err := dialerFunc() - if err != nil { - return nil, err - } - d.mut.Lock() - d.dialers[key] = dialer - d.atime[key] = time.Now() - d.mut.Unlock() - - return dialer, nil - }) - if err != nil { - return nil, false, err - } - select { - case <-d.closed: - return nil, false, errors.New("cache closed") - default: - } - - return rawDialer.(*Dialer), false, nil -} - -// Close closes all cached dialers. -func (d *DialerCache) Close() error { - d.mut.Lock() - defer d.mut.Unlock() - - for _, dialer := range d.dialers { - err := dialer.Close() - if err != nil { - return err - } - } - close(d.closed) - return nil -} diff --git a/wsnet/cache_test.go b/wsnet/cache_test.go deleted file mode 100644 index 44edb608..00000000 --- a/wsnet/cache_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package wsnet - -import ( - "context" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCache(t *testing.T) { - dialFunc := func(connectAddr string) func() (*Dialer, error) { - return func() (*Dialer, error) { - return DialWebsocket(context.Background(), connectAddr, nil, nil) - } - } - - t.Run("Caches", func(t *testing.T) { - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), slogtest.Make(t, nil), listenAddr, "") - require.NoError(t, err) - defer l.Close() - - cache := DialCache(time.Hour) - c1, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, false) - c2, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, true) - assert.Same(t, c1, c2) - }) - - t.Run("Create If Closed", func(t *testing.T) { - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), slogtest.Make(t, nil), listenAddr, "") - require.NoError(t, err) - defer l.Close() - - cache := DialCache(time.Hour) - - c1, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, false) - require.NoError(t, c1.Close()) - c2, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, false) - assert.NotSame(t, c1, c2) - }) - - t.Run("Evict No Connections", func(t *testing.T) { - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), slogtest.Make(t, nil), listenAddr, "") - require.NoError(t, err) - defer l.Close() - - cache := DialCache(0) - - c1, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, false) - cache.evict() - c2, cached, err := cache.Dial(context.Background(), "example", dialFunc(connectAddr)) - require.NoError(t, err) - require.Equal(t, cached, false) - assert.NotSame(t, c1, c2) - }) -} diff --git a/wsnet/conn.go b/wsnet/conn.go deleted file mode 100644 index 40fa50ae..00000000 --- a/wsnet/conn.go +++ /dev/null @@ -1,200 +0,0 @@ -package wsnet - -import ( - "context" - "errors" - "fmt" - "net" - "net/http" - "net/url" - "sync" - "time" - - "github.com/pion/datachannel" - "github.com/pion/webrtc/v3" - "nhooyr.io/websocket" - - "cdr.dev/coder-cli/coder-sdk" -) - -const ( - httpScheme = "http" - turnProxyMagicUsername = "~magicalusername~" - - bufferedAmountLowThreshold uint64 = 512 * 1024 // 512 KB - maxBufferedAmount uint64 = 1024 * 1024 // 1 MB - // For some reason messages larger just don't work... - // This shouldn't be a huge deal for real-world usage. - // See: https://github.com/pion/datachannel/issues/59 - maxMessageLength = 32 * 1024 // 32 KB -) - -// ListenEndpoint returns the Coder endpoint to listen for workspace connections. -func ListenEndpoint(baseURL *url.URL, token string) string { - wsScheme := "wss" - if baseURL.Scheme == httpScheme { - wsScheme = "ws" - } - return fmt.Sprintf("%s://%s%s?service_token=%s", wsScheme, baseURL.Host, "/api/private/envagent/listen", token) -} - -// ConnectEndpoint returns the Coder endpoint to dial a connection for a workspace. -func ConnectEndpoint(baseURL *url.URL, workspace, token string) string { - wsScheme := "wss" - if baseURL.Scheme == httpScheme { - wsScheme = "ws" - } - return fmt.Sprintf("%s://%s%s%s%s%s", wsScheme, baseURL.Host, "/api/private/envagent/", workspace, "/connect?session_token=", token) -} - -// TURNWebSocketICECandidate returns a fake TCP relay ICEServer. -// It's used to trigger the ICEProxyDialer. -func TURNProxyICECandidate() webrtc.ICEServer { - return webrtc.ICEServer{ - URLs: []string{"turn:127.0.0.1:3478?transport=tcp"}, - Username: turnProxyMagicUsername, - Credential: turnProxyMagicUsername, - CredentialType: webrtc.ICECredentialTypePassword, - } -} - -// Proxies all TURN ICEServer traffic through this dialer. -// References Coder APIs with a specific token. -type turnProxyDialer struct { - baseURL *url.URL - token string -} - -func (t *turnProxyDialer) Dial(network, addr string) (c net.Conn, err error) { - headers := http.Header{} - headers.Set("Session-Token", t.token) - - ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) - defer cancel() - - // Copy the baseURL so we can adjust path. - url := *t.baseURL - switch url.Scheme { - case "http": - url.Scheme = "ws" - case "https": - url.Scheme = "wss" - default: - return nil, errors.New("invalid turn url addr scheme provided") - } - url.Path = "/api/private/turn" - conn, resp, err := websocket.Dial(ctx, url.String(), &websocket.DialOptions{ - HTTPHeader: headers, - }) - if err != nil { - if resp != nil { - defer resp.Body.Close() - return nil, coder.NewHTTPError(resp) - } - return nil, fmt.Errorf("dial: %w", err) - } - - return &turnProxyConn{ - websocket.NetConn(context.Background(), conn, websocket.MessageBinary), - }, nil -} - -// turnProxyConn is a net.Conn wrapper that returns a TCPAddr for the -// LocalAddr function. pion/ice unsafely checks the types. See: -// https://github.com/pion/ice/blob/e78f26fb435987420546c70369ade5d713beca39/gather.go#L448 -type turnProxyConn struct { - net.Conn -} - -// The LocalAddr specified here doesn't really matter, -// it just has to be of type "TCPAddr". -func (*turnProxyConn) LocalAddr() net.Addr { - return &net.TCPAddr{ - IP: net.IPv4(127, 0, 0, 1), - Port: 0, - } -} - -// Properly buffers data for data channel connections. -type dataChannelConn struct { - addr *net.UnixAddr - dc *webrtc.DataChannel - rw datachannel.ReadWriteCloser - - sendMore chan struct{} - closedMutex sync.RWMutex - closed bool - - writeMutex sync.Mutex -} - -func (c *dataChannelConn) init() { - c.closedMutex.Lock() - defer c.closedMutex.Unlock() - c.sendMore = make(chan struct{}, 1) - c.dc.SetBufferedAmountLowThreshold(bufferedAmountLowThreshold) - c.dc.OnBufferedAmountLow(func() { - c.closedMutex.RLock() - defer c.closedMutex.RUnlock() - if c.closed { - return - } - select { - case c.sendMore <- struct{}{}: - default: - } - }) -} - -func (c *dataChannelConn) Read(b []byte) (n int, err error) { - return c.rw.Read(b) -} - -func (c *dataChannelConn) Write(b []byte) (n int, err error) { - c.writeMutex.Lock() - defer c.writeMutex.Unlock() - if len(b) > maxMessageLength { - return 0, fmt.Errorf("outbound packet larger than maximum message size: %d", maxMessageLength) - } - if c.dc.BufferedAmount()+uint64(len(b)) >= maxBufferedAmount { - <-c.sendMore - } - // TODO (@kyle): There's an obvious race-condition here. - // This is an edge-case, as most-frequently data won't - // be pooled so synchronously, but is definitely possible. - // - // See: https://github.com/pion/sctp/issues/181 - time.Sleep(time.Microsecond) - - return c.rw.Write(b) -} - -func (c *dataChannelConn) Close() error { - c.closedMutex.Lock() - defer c.closedMutex.Unlock() - if !c.closed { - c.closed = true - close(c.sendMore) - } - return c.dc.Close() -} - -func (c *dataChannelConn) LocalAddr() net.Addr { - return c.addr -} - -func (c *dataChannelConn) RemoteAddr() net.Addr { - return c.addr -} - -func (c *dataChannelConn) SetDeadline(t time.Time) error { - return nil -} - -func (c *dataChannelConn) SetReadDeadline(t time.Time) error { - return nil -} - -func (c *dataChannelConn) SetWriteDeadline(t time.Time) error { - return nil -} diff --git a/wsnet/dial.go b/wsnet/dial.go deleted file mode 100644 index 35e13870..00000000 --- a/wsnet/dial.go +++ /dev/null @@ -1,440 +0,0 @@ -package wsnet - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/url" - "os" - "sync" - "time" - - "github.com/pion/datachannel" - "github.com/pion/webrtc/v3" - "golang.org/x/net/proxy" - "nhooyr.io/websocket" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/sloghuman" - - "cdr.dev/coder-cli/coder-sdk" -) - -// DialOptions are configurable options for a wsnet connection. -type DialOptions struct { - // Logger is an optional logger to use for logging mostly debug messages. If - // set to nil, nothing will be logged. - Log *slog.Logger - - // ICEServers is an array of STUN or TURN servers to use for negotiation purposes. - // See: https://developer.mozilla.org/en-US/docs/Web/API/RTCConfiguration/iceServers - ICEServers []webrtc.ICEServer - - // TURNProxyAuthToken is used to authenticate a TURN proxy request. - TURNProxyAuthToken string - - // TURNRemoteProxyURL is the URL to proxy listener TURN data through. - TURNRemoteProxyURL *url.URL - - // TURNLocalProxyURL is the URL to proxy client TURN data through. - TURNLocalProxyURL *url.URL -} - -// DialWebsocket dials the broker with a WebSocket and negotiates a connection. -func DialWebsocket(ctx context.Context, broker string, netOpts *DialOptions, wsOpts *websocket.DialOptions) (*Dialer, error) { - if netOpts == nil { - netOpts = &DialOptions{} - } - if netOpts.Log == nil { - // This logger will log nothing. - log := slog.Make() - netOpts.Log = &log - } - log := *netOpts.Log - - log.Debug(ctx, "connecting to broker", slog.F("broker", broker)) - conn, resp, err := websocket.Dial(ctx, broker, wsOpts) - if err != nil { - if resp != nil { - defer func() { - _ = resp.Body.Close() - }() - return nil, coder.NewHTTPError(resp) - } - return nil, fmt.Errorf("dial websocket: %w", err) - } - log.Debug(ctx, "connected to broker") - - nconn := websocket.NetConn(ctx, conn, websocket.MessageBinary) - defer func() { - _ = nconn.Close() - // We should close the socket intentionally. - _ = conn.Close(websocket.StatusInternalError, "an error occurred") - }() - return Dial(ctx, nconn, netOpts) -} - -// Dial negotiates a connection to a listener. -func Dial(ctx context.Context, conn net.Conn, options *DialOptions) (*Dialer, error) { - if options == nil { - options = &DialOptions{} - } - if options.Log == nil { - log := slog.Make(sloghuman.Sink(os.Stderr)).Leveled(slog.LevelInfo).Named("wsnet_dial") - options.Log = &log - } - log := *options.Log - if options.ICEServers == nil { - options.ICEServers = []webrtc.ICEServer{} - } - - var turnProxy proxy.Dialer - if options.TURNLocalProxyURL != nil { - turnProxy = &turnProxyDialer{ - baseURL: options.TURNLocalProxyURL, - token: options.TURNProxyAuthToken, - } - } - - log.Debug(ctx, "creating peer connection", slog.F("options", options), slog.F("turn_proxy", turnProxy)) - rtc, err := newPeerConnection(options.ICEServers, turnProxy) - if err != nil { - return nil, fmt.Errorf("create peer connection: %w", err) - } - log.Debug(ctx, "created peer connection") - defer func() { - if err != nil { - // Wrap our error with some extra details. - err = errWrap{ - err: err, - iceServers: rtc.GetConfiguration().ICEServers, - rtc: rtc.ConnectionState(), - } - } - }() - - rtc.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) { - log.Debug(ctx, "connection state change", slog.F("state", pcs.String())) - }) - - flushCandidates := proxyICECandidates(rtc, conn) - - log.Debug(ctx, "creating control channel", slog.F("proto", controlChannel)) - ctrl, err := rtc.CreateDataChannel(controlChannel, &webrtc.DataChannelInit{ - Protocol: stringPtr(controlChannel), - Ordered: boolPtr(true), - }) - if err != nil { - return nil, fmt.Errorf("create control channel: %w", err) - } - - offer, err := rtc.CreateOffer(&webrtc.OfferOptions{}) - if err != nil { - return nil, fmt.Errorf("create offer: %w", err) - } - log.Debug(ctx, "created offer", slog.F("offer", offer)) - err = rtc.SetLocalDescription(offer) - if err != nil { - return nil, fmt.Errorf("set local offer: %w", err) - } - - var turnProxyURL string - if options.TURNRemoteProxyURL != nil { - turnProxyURL = options.TURNRemoteProxyURL.String() - } - - bmsg := BrokerMessage{ - Offer: &offer, - Servers: options.ICEServers, - TURNProxyURL: turnProxyURL, - } - log.Debug(ctx, "sending offer message", slog.F("msg", bmsg)) - offerMessage, err := json.Marshal(&bmsg) - if err != nil { - return nil, fmt.Errorf("marshal offer message: %w", err) - } - - _, err = conn.Write(offerMessage) - if err != nil { - return nil, fmt.Errorf("write offer: %w", err) - } - flushCandidates() - - dialer := &Dialer{ - log: log, - conn: conn, - ctrl: ctrl, - rtc: rtc, - connClosers: []io.Closer{ctrl}, - } - - // This is on a separate line so the defer above catches it. - err = dialer.negotiate(ctx) - return dialer, err -} - -// Dialer enables arbitrary dialing to any network and address -// inside a workspace. The opposing end of the WebSocket messages -// should be proxied with a Listener. -type Dialer struct { - log slog.Logger - conn net.Conn - ctrl *webrtc.DataChannel - ctrlrw datachannel.ReadWriteCloser - rtc *webrtc.PeerConnection - - connClosers []io.Closer - connClosersMut sync.Mutex - pingMut sync.Mutex -} - -func (d *Dialer) negotiate(ctx context.Context) (err error) { - var ( - decoder = json.NewDecoder(d.conn) - errCh = make(chan error) - // If candidates are sent before an offer, we place them here. - // We currently have no assurances to ensure this can't happen, - // so it's better to buffer and process than fail. - pendingCandidates = []webrtc.ICECandidateInit{} - ) - go func() { - defer close(errCh) - defer func() { - _ = d.conn.Close() - }() - - err := waitForConnectionOpen(context.Background(), d.rtc) - if err != nil { - d.log.Debug(ctx, "negotiation error", slog.Error(err)) - if errors.Is(err, context.DeadlineExceeded) { - _ = d.conn.Close() - } - errCh <- fmt.Errorf("wait for connection to open: %w", err) - return - } - - d.rtc.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) { - if pcs == webrtc.PeerConnectionStateConnected { - d.log.Debug(ctx, "connected") - return - } - - // Close connections opened when RTC was alive. - d.log.Warn(ctx, "closing connections due to connection state change", slog.F("pcs", pcs.String())) - d.connClosersMut.Lock() - defer d.connClosersMut.Unlock() - for _, connCloser := range d.connClosers { - _ = connCloser.Close() - } - d.connClosers = make([]io.Closer, 0) - }) - }() - - d.log.Debug(ctx, "beginning negotiation") - for { - var msg BrokerMessage - err = decoder.Decode(&msg) - if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) { - break - } - if err != nil { - return fmt.Errorf("read: %w", err) - } - d.log.Debug(ctx, "got message from handshake conn", slog.F("msg", msg)) - - if msg.Candidate != "" { - c := webrtc.ICECandidateInit{ - Candidate: msg.Candidate, - } - if d.rtc.RemoteDescription() == nil { - pendingCandidates = append(pendingCandidates, c) - continue - } - - d.log.Debug(ctx, "adding remote ICE candidate", slog.F("c", c)) - err = d.rtc.AddICECandidate(c) - if err != nil { - return fmt.Errorf("accept ice candidate: %s: %w", msg.Candidate, err) - } - continue - } - - if msg.Answer != nil { - d.log.Debug(ctx, "received answer", slog.F("a", *msg.Answer)) - err = d.rtc.SetRemoteDescription(*msg.Answer) - if err != nil { - return fmt.Errorf("set answer: %w", err) - } - - for _, candidate := range pendingCandidates { - err = d.rtc.AddICECandidate(candidate) - if err != nil { - return fmt.Errorf("accept pending ice candidate: %s: %w", candidate.Candidate, err) - } - } - pendingCandidates = nil - continue - } - - if msg.Error != "" { - d.log.Debug(ctx, "got error from peer", slog.F("err", msg.Error)) - return fmt.Errorf("error from peer: %v", msg.Error) - } - - return fmt.Errorf("unhandled message: %+v", msg) - } - - return <-errCh -} - -// ActiveConnections returns the amount of active connections. -// DialContext opens a connection, and close will end it. -func (d *Dialer) activeConnections() int { - stats, ok := d.rtc.GetStats().GetConnectionStats(d.rtc) - if !ok { - return -1 - } - // Subtract 1 for the control channel. - return int(stats.DataChannelsRequested-stats.DataChannelsClosed) - 1 -} - -// Candidates returns the candidate pair that was chosen for the connection. -func (d *Dialer) Candidates() (*webrtc.ICECandidatePair, error) { - return d.rtc.SCTP().Transport().ICETransport().GetSelectedCandidatePair() -} - -// Close closes the RTC connection. -// All data channels dialed will be closed. -func (d *Dialer) Close() error { - d.log.Debug(context.Background(), "close called") - return d.rtc.Close() -} - -// Ping sends a ping through the control channel. -func (d *Dialer) Ping(ctx context.Context) error { - if d.ctrl.ReadyState() == webrtc.DataChannelStateClosed || d.ctrl.ReadyState() == webrtc.DataChannelStateClosing { - return webrtc.ErrConnectionClosed - } - - // Since we control the client and server we could open this - // data channel with `Negotiated` true to reduce traffic being - // sent when the RTC connection is opened. - err := waitForDataChannelOpen(ctx, d.ctrl) - if err != nil { - return err - } - if d.ctrlrw == nil { - d.ctrlrw, err = d.ctrl.Detach() - if err != nil { - return err - } - } - d.pingMut.Lock() - defer d.pingMut.Unlock() - d.log.Debug(ctx, "sending ping") - _, err = d.ctrlrw.Write([]byte{'a'}) - if err != nil { - return fmt.Errorf("write: %w", err) - } - errCh := make(chan error) - go func() { - // There's a race in which connections can get lost-mid ping - // in which case this would block forever. - defer close(errCh) - _, err = d.ctrlrw.Read(make([]byte, 4)) - errCh <- err - }() - ctx, cancelFunc := context.WithTimeout(ctx, time.Second*15) - defer cancelFunc() - select { - case err := <-errCh: - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -// DialContext dials the network and address on the remote listener. -func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { - proto := fmt.Sprintf("%s:%s", network, address) - ctx = slog.With(ctx, slog.F("proto", proto)) - - d.log.Debug(ctx, "opening data channel") - dc, err := d.rtc.CreateDataChannel("proxy", &webrtc.DataChannelInit{ - Ordered: boolPtr(network != "udp"), - Protocol: &proto, - }) - if err != nil { - return nil, fmt.Errorf("create data channel: %w", err) - } - - d.connClosersMut.Lock() - d.connClosers = append(d.connClosers, dc) - d.connClosersMut.Unlock() - - err = waitForDataChannelOpen(ctx, dc) - if err != nil { - return nil, fmt.Errorf("wait for open: %w", err) - } - - ctx = slog.With(ctx, slog.F("dc_id", dc.ID())) - d.log.Debug(ctx, "data channel opened") - - rw, err := dc.Detach() - if err != nil { - return nil, fmt.Errorf("detach: %w", err) - } - d.log.Debug(ctx, "data channel detached") - - ctx, cancel := context.WithTimeout(ctx, time.Second*5) - defer cancel() - - errCh := make(chan error) - go func() { - var res DialChannelResponse - err = json.NewDecoder(rw).Decode(&res) - if err != nil { - errCh <- fmt.Errorf("read dial response: %w", err) - return - } - d.log.Debug(ctx, "dial response", slog.F("res", res)) - if res.Err == "" { - close(errCh) - return - } - err := errors.New(res.Err) - if res.Code == CodeDialErr { - err = &net.OpError{ - Op: res.Op, - Net: res.Net, - Err: err, - } - } - errCh <- err - }() - - select { - case err := <-errCh: - if err != nil { - return nil, err - } - case <-ctx.Done(): - return nil, ctx.Err() - } - - c := &dataChannelConn{ - addr: &net.UnixAddr{ - Name: address, - Net: network, - }, - dc: dc, - rw: rw, - } - c.init() - - d.log.Debug(ctx, "dial channel ready") - return c, nil -} diff --git a/wsnet/dial_test.go b/wsnet/dial_test.go deleted file mode 100644 index a9b09417..00000000 --- a/wsnet/dial_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package wsnet - -import ( - "context" - "crypto/rand" - "errors" - "fmt" - "io" - "net" - "strconv" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest" - "github.com/pion/ice/v2" - "github.com/pion/webrtc/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func ExampleDial_basic() { - servers := []webrtc.ICEServer{{ - URLs: []string{"turns:master.cdr.dev"}, - Username: "kyle", - Credential: "pass", - CredentialType: webrtc.ICECredentialTypePassword, - }} - - for _, server := range servers { - err := DialICE(server, nil) - if errors.Is(err, ErrInvalidCredentials) { - // You could do something... - } - if errors.Is(err, ErrMismatchedProtocol) { - // Likely they used TURNS when they should have used TURN. - // Or they could have used TURN instead of TURNS. - } - } - - dialer, err := DialWebsocket(context.Background(), "wss://master.cdr.dev/agent/workspace/connect", &DialOptions{ - ICEServers: servers, - }, nil) - if err != nil { - // Do something... - } - conn, err := dialer.DialContext(context.Background(), "tcp", "localhost:13337") - if err != nil { - // Something... - } - defer conn.Close() - // You now have access to the proxied remote port in `conn`. -} - -func TestDial(t *testing.T) { - t.Run("Timeout", func(t *testing.T) { - t.Parallel() - - connectAddr, _ := createDumbBroker(t) - - ctx, cancelFunc := context.WithTimeout(context.Background(), time.Millisecond*50) - defer cancelFunc() - dialer, err := DialWebsocket(ctx, connectAddr, nil, nil) - require.True(t, errors.Is(err, context.DeadlineExceeded)) - require.Error(t, dialer.conn.Close(), "already wrote close") - }) - - t.Run("Ping", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - - err = dialer.Ping(context.Background()) - require.NoError(t, err) - }) - - t.Run("Ping Close", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - turnAddr, closeTurn := createTURNServer(t, ice.SchemeTypeTURN) - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - ICEServers: []webrtc.ICEServer{{ - URLs: []string{fmt.Sprintf("turn:%s", turnAddr)}, - Username: "example", - Credential: testPass, - CredentialType: webrtc.ICECredentialTypePassword, - }}, - }, nil) - require.NoError(t, err) - - _ = dialer.Ping(context.Background()) - closeTurn() - err = dialer.Ping(context.Background()) - assert.Error(t, err) - assert.ErrorIs(t, err, io.EOF) - }) - - t.Run("OPError", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - - _, err = dialer.DialContext(context.Background(), "tcp", "localhost:100") - assert.Error(t, err) - - // Double pointer intended. - netErr := &net.OpError{} - assert.ErrorAs(t, err, &netErr) - }) - - t.Run("Proxy", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - listener, err := net.Listen("tcp", "0.0.0.0:0") - require.NoError(t, err) - - msg := []byte("Hello!") - go func() { - conn, err := listener.Accept() - require.NoError(t, err) - - _, _ = conn.Write(msg) - }() - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - - conn, err := dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - require.NoError(t, err) - - rec := make([]byte, len(msg)) - _, err = conn.Read(rec) - require.NoError(t, err) - - assert.Equal(t, msg, rec) - }) - - // Expect that we'd get an EOF on the server closing. - t.Run("EOF on Close", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - listener, err := net.Listen("tcp", "0.0.0.0:0") - require.NoError(t, err) - go func() { - _, _ = listener.Accept() - }() - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - - conn, err := dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - require.NoError(t, err) - - go l.Close() - rec := make([]byte, 16) - _, err = conn.Read(rec) - assert.ErrorIs(t, err, io.EOF) - }) - - t.Run("Disconnect", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - - err = dialer.Close() - require.NoError(t, err) - - err = dialer.Ping(context.Background()) - assert.ErrorIs(t, err, webrtc.ErrConnectionClosed) - }) - - t.Run("Disconnect DialContext", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - tcpListener, err := net.Listen("tcp", "0.0.0.0:0") - require.NoError(t, err) - go func() { - _, _ = tcpListener.Accept() - }() - - connectAddr, listenAddr := createDumbBroker(t) - l, err := Listen(context.Background(), log, listenAddr, "") - require.NoError(t, err) - defer l.Close() - - turnAddr, closeTurn := createTURNServer(t, ice.SchemeTypeTURN) - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - ICEServers: []webrtc.ICEServer{{ - URLs: []string{fmt.Sprintf("turn:%s", turnAddr)}, - Username: "example", - Credential: testPass, - CredentialType: webrtc.ICECredentialTypePassword, - }}, - }, nil) - require.NoError(t, err) - - conn, err := dialer.DialContext(context.Background(), "tcp", tcpListener.Addr().String()) - require.NoError(t, err) - - // Close the TURN server before reading... - // WebRTC connections take a few seconds to timeout. - closeTurn() - _, err = conn.Read(make([]byte, 16)) - assert.ErrorIs(t, err, io.EOF) - }) - - t.Run("Active Connections", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - listener, err := net.Listen("tcp", "0.0.0.0:0") - if err != nil { - t.Error(err) - return - } - go func() { - _, _ = listener.Accept() - }() - connectAddr, listenAddr := createDumbBroker(t) - _, err = Listen(context.Background(), slogtest.Make(t, nil), listenAddr, "") - if err != nil { - t.Error(err) - return - } - - dialer, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - if err != nil { - t.Error(err) - } - conn, _ := dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - assert.Equal(t, 1, dialer.activeConnections()) - _ = conn.Close() - assert.Equal(t, 0, dialer.activeConnections()) - _, _ = dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - conn, _ = dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - assert.Equal(t, 2, dialer.activeConnections()) - _ = conn.Close() - assert.Equal(t, 1, dialer.activeConnections()) - }) - - t.Run("Close Listeners on Disconnect", func(t *testing.T) { - t.Parallel() - log := slogtest.Make(t, nil) - - listener, err := net.Listen("tcp", "0.0.0.0:0") - require.NoError(t, err) - go func() { - for { - c, _ := listener.Accept() - - go func() { - b := make([]byte, 5) - _, err := c.Read(b) - if err != nil { - return - } - _, err = c.Write(b) - require.NoError(t, err) - }() - } - }() - connectAddr, listenAddr := createDumbBroker(t) - _, err = Listen(context.Background(), slogtest.Make(t, nil), listenAddr, "") - require.NoError(t, err) - - d1, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - _, err = d1.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - require.NoError(t, err) - - d2, err := DialWebsocket(context.Background(), connectAddr, &DialOptions{ - Log: &log, - }, nil) - require.NoError(t, err) - conn, err := d2.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - require.NoError(t, err) - err = d1.Close() - require.NoError(t, err) - - // TODO: This needs to be longer than the KeepAlive timeout for the RTC connection. - // Once the listener stores RTC connections instead of io.Closer we can directly - // reference the RTC connection to ensure it's properly closed. - time.Sleep(time.Second * 10) - - b := []byte("hello") - _, err = conn.Write(b) - require.NoError(t, err) - _, err = conn.Read(b) - require.NoError(t, err) - }) -} - -func BenchmarkThroughput(b *testing.B) { - sizes := []int64{ - 4, - 16, - 128, - 256, - 1024, - 4096, - 16384, - 32768, - } - - listener, err := net.Listen("tcp", "0.0.0.0:0") - if err != nil { - b.Error(err) - return - } - go func() { - for { - conn, err := listener.Accept() - if err != nil { - b.Error(err) - return - } - go func() { - _, _ = io.Copy(io.Discard, conn) - }() - } - }() - connectAddr, listenAddr := createDumbBroker(b) - l, err := Listen(context.Background(), slogtest.Make(b, nil), listenAddr, "") - if err != nil { - b.Error(err) - return - } - defer l.Close() - - dialer, err := DialWebsocket(context.Background(), connectAddr, nil, nil) - if err != nil { - b.Error(err) - return - } - for _, size := range sizes { - size := size - bytes := make([]byte, size) - _, _ = rand.Read(bytes) - b.Run("Rand"+strconv.Itoa(int(size)), func(b *testing.B) { - b.SetBytes(size) - b.ReportAllocs() - - conn, err := dialer.DialContext(context.Background(), listener.Addr().Network(), listener.Addr().String()) - if err != nil { - b.Error(err) - return - } - defer conn.Close() - - for i := 0; i < b.N; i++ { - _, err := conn.Write(bytes) - if err != nil { - b.Error(err) - break - } - } - }) - } -} diff --git a/wsnet/doc.go b/wsnet/doc.go deleted file mode 100644 index 3cbdc3ce..00000000 --- a/wsnet/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package wsnet handles client and server ends of Workspace networking -// negotiations and protocol. -package wsnet diff --git a/wsnet/error.go b/wsnet/error.go deleted file mode 100644 index aa66d548..00000000 --- a/wsnet/error.go +++ /dev/null @@ -1,39 +0,0 @@ -package wsnet - -import ( - "fmt" - "strings" - - "github.com/pion/webrtc/v3" -) - -// errWrap wraps the error with some extra details about the state of the -// connection. -type errWrap struct { - err error - - iceServers []webrtc.ICEServer - rtc webrtc.PeerConnectionState -} - -var _ error = errWrap{} -var _ interface{ Unwrap() error } = errWrap{} - -// Error implements error. -func (e errWrap) Error() string { - return fmt.Sprintf("%v (ice: [%v], rtc: %v)", e.err.Error(), e.ice(), e.rtc.String()) -} - -func (e errWrap) ice() string { - msgs := []string{} - for _, s := range e.iceServers { - msgs = append(msgs, strings.Join(s.URLs, ", ")) - } - - return strings.Join(msgs, ", ") -} - -// Unwrap implements Unwrapper. -func (e errWrap) Unwrap() error { - return e.err -} diff --git a/wsnet/listen.go b/wsnet/listen.go deleted file mode 100644 index 78002899..00000000 --- a/wsnet/listen.go +++ /dev/null @@ -1,477 +0,0 @@ -package wsnet - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/url" - "sync" - "sync/atomic" - "time" - - "github.com/hashicorp/yamux" - "github.com/pion/webrtc/v3" - "golang.org/x/net/proxy" - "nhooyr.io/websocket" - - "cdr.dev/slog" - - "cdr.dev/coder-cli/coder-sdk" -) - -// Codes for DialChannelResponse. -const ( - CodeDialErr = "dial_error" - CodePermissionErr = "permission_error" - CodeBadAddressErr = "bad_address_error" -) - -var connectionRetryInterval = time.Second - -// DialChannelResponse is used to notify a dial channel of a -// listening state. Modeled after net.OpError, and marshalled -// to that if Net is not "". -type DialChannelResponse struct { - Code string - Err string - // Fields are set if the code is CodeDialErr. - Net string - Op string -} - -// Listen connects to the broker proxies connections to the local net. -// Close will end all RTC connections. -func Listen(ctx context.Context, log slog.Logger, broker string, turnProxyAuthToken string) (io.Closer, error) { - l := &listener{ - log: log, - broker: broker, - connClosers: make([]io.Closer, 0), - closed: make(chan struct{}, 1), - turnProxyAuthToken: turnProxyAuthToken, - } - - // We do a one-off dial outside of the loop to ensure the initial - // connection is successful. If not, there's likely an error the - // user needs to act on. - ch, err := l.dial(ctx) - if err != nil { - return nil, err - } - go func() { - for { - err := <-ch - select { - case _, ok := <-l.closed: - if !ok { - return - } - default: - } - - if err != nil { - l.log.Warn(ctx, "disconnected from broker", slog.Error(err)) - - // If we hit an EOF, then the connection to the broker - // was interrupted. We'll take a short break then dial - // again. - ticker := time.NewTicker(connectionRetryInterval) - for { - select { - case <-ticker.C: - ch, err = l.dial(ctx) - case <-ctx.Done(): - err = ctx.Err() - } - if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - break - } - l.log.Warn(ctx, "connecting to broker failed", slog.Error(err)) - } - ticker.Stop() - } - l.log.Info(ctx, "connected to broker") - } - }() - return l, nil -} - -type listener struct { - broker string - turnProxyAuthToken string - - log slog.Logger - ws *websocket.Conn - connClosers []io.Closer - connClosersMut sync.Mutex - closed chan struct{} - nextConnNumber int64 -} - -func (l *listener) dial(ctx context.Context) (<-chan error, error) { - l.log.Info(ctx, "connecting to broker", slog.F("broker_url", l.broker)) - if l.ws != nil { - _ = l.ws.Close(websocket.StatusNormalClosure, "new connection inbound") - } - - conn, resp, err := websocket.Dial(ctx, l.broker, nil) - if err != nil { - if resp != nil { - return nil, coder.NewHTTPError(resp) - } - return nil, err - } - l.ws = conn - - nconn := websocket.NetConn(ctx, conn, websocket.MessageBinary) - config := yamux.DefaultConfig() - config.LogOutput = io.Discard - session, err := yamux.Server(nconn, config) - if err != nil { - return nil, fmt.Errorf("create multiplex: %w", err) - } - - l.log.Info(ctx, "broker connection established") - errCh := make(chan error) - go func() { - defer close(errCh) - for { - conn, err := session.Accept() - if err != nil { - errCh <- err - break - } - go l.negotiate(ctx, conn) - } - }() - - return errCh, nil -} - -// Negotiates the handshake protocol over the connection provided. -// This functions control-flow is important to readability, -// so the cognitive overload linter has been disabled. -// nolint:gocognit,nestif -func (l *listener) negotiate(ctx context.Context, conn net.Conn) { - id := atomic.AddInt64(&l.nextConnNumber, 1) - ctx = slog.With(ctx, slog.F("conn_id", id)) - - var ( - err error - decoder = json.NewDecoder(conn) - rtc *webrtc.PeerConnection - connClosers = make([]io.Closer, 0) - connClosersMut sync.Mutex - // If candidates are sent before an offer, we place them here. - // We currently have no assurances to ensure this can't happen, - // so it's better to buffer and process than fail. - pendingCandidates = []webrtc.ICECandidateInit{} - // Sends the error provided then closes the connection. - // If RTC isn't connected, we'll close it. - closeError = func(err error) { - // l.log.Warn(ctx, "negotiation error, closing connection", slog.Error(err)) - - d, _ := json.Marshal(&BrokerMessage{ - Error: err.Error(), - }) - _, _ = conn.Write(d) - _ = conn.Close() - if rtc != nil { - if rtc.ConnectionState() != webrtc.PeerConnectionStateConnected { - rtc.Close() - rtc = nil - } - } - } - ) - - l.log.Info(ctx, "accepted new session from broker connection, negotiating") - - for { - var msg BrokerMessage - err = decoder.Decode(&msg) - if err != nil { - closeError(err) - return - } - l.log.Debug(ctx, "received broker message", slog.F("msg", msg)) - - if msg.Candidate != "" { - c := webrtc.ICECandidateInit{ - Candidate: msg.Candidate, - } - - if rtc == nil { - pendingCandidates = append(pendingCandidates, c) - continue - } - - l.log.Debug(ctx, "adding ICE candidate", slog.F("c", c)) - err = rtc.AddICECandidate(c) - if err != nil { - closeError(fmt.Errorf("accept ice candidate: %w", err)) - return - } - } - - if msg.Offer != nil { - if msg.Servers == nil { - closeError(fmt.Errorf("ICEServers must be provided")) - return - } - for _, server := range msg.Servers { - if server.Username == turnProxyMagicUsername { - // This candidate is only used when proxying, - // so it will not validate. - continue - } - - l.log.Debug(ctx, "validating ICE server", slog.F("s", server)) - err = DialICE(server, nil) - if err != nil { - closeError(fmt.Errorf("dial server %+v: %w", server.URLs, err)) - return - } - } - - var turnProxy proxy.Dialer - if msg.TURNProxyURL != "" { - u, err := url.Parse(msg.TURNProxyURL) - if err != nil { - closeError(fmt.Errorf("parse turn proxy url: %w", err)) - return - } - turnProxy = &turnProxyDialer{ - baseURL: u, - token: l.turnProxyAuthToken, - } - } - rtc, err = newPeerConnection(msg.Servers, turnProxy) - if err != nil { - closeError(err) - return - } - l.connClosersMut.Lock() - l.connClosers = append(l.connClosers, rtc) - l.connClosersMut.Unlock() - rtc.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) { - l.log.Info(ctx, "connection state change", slog.F("state", pcs.String())) - switch pcs { - case webrtc.PeerConnectionStateConnected: - return - case webrtc.PeerConnectionStateConnecting: - // Safe to close the negotiating WebSocket. - _ = conn.Close() - return - } - - // Close connections opened when RTC was alive. - connClosersMut.Lock() - defer connClosersMut.Unlock() - for _, connCloser := range connClosers { - _ = connCloser.Close() - } - connClosers = make([]io.Closer, 0) - }) - - flushCandidates := proxyICECandidates(rtc, conn) - rtc.OnDataChannel(l.handle(ctx, msg, &connClosers, &connClosersMut)) - - l.log.Debug(ctx, "set remote description", slog.F("offer", *msg.Offer)) - err = rtc.SetRemoteDescription(*msg.Offer) - if err != nil { - closeError(fmt.Errorf("apply offer: %w", err)) - return - } - - answer, err := rtc.CreateAnswer(nil) - if err != nil { - closeError(fmt.Errorf("create answer: %w", err)) - return - } - - l.log.Debug(ctx, "set local description", slog.F("answer", answer)) - err = rtc.SetLocalDescription(answer) - if err != nil { - closeError(fmt.Errorf("set local answer: %w", err)) - return - } - flushCandidates() - - bmsg := &BrokerMessage{ - Answer: rtc.LocalDescription(), - } - data, err := json.Marshal(bmsg) - if err != nil { - closeError(fmt.Errorf("marshal: %w", err)) - return - } - - l.log.Debug(ctx, "writing message", slog.F("msg", bmsg)) - _, err = conn.Write(data) - if err != nil { - closeError(fmt.Errorf("write: %w", err)) - return - } - - for _, candidate := range pendingCandidates { - l.log.Debug(ctx, "adding pending ICE candidate", slog.F("c", candidate)) - err = rtc.AddICECandidate(candidate) - if err != nil { - closeError(fmt.Errorf("add pending candidate: %w", err)) - return - } - } - pendingCandidates = nil - } - } -} - -// nolint:gocognit -func (l *listener) handle(ctx context.Context, msg BrokerMessage, connClosers *[]io.Closer, connClosersMut *sync.Mutex) func(dc *webrtc.DataChannel) { - return func(dc *webrtc.DataChannel) { - if dc.Protocol() == controlChannel { - // The control channel handles pings. - dc.OnOpen(func() { - l.log.Debug(ctx, "control channel open") - rw, err := dc.Detach() - if err != nil { - return - } - // We'll read and write back a single byte for ping/pongin'. - d := make([]byte, 1) - for { - l.log.Debug(ctx, "sending ping") - _, err = rw.Read(d) - if err != nil { - l.log.Debug(ctx, "reading ping response failed", slog.Error(err)) - } - if errors.Is(err, io.EOF) { - return - } - if err != nil { - continue - } - _, _ = rw.Write(d) - } - }) - return - } - - ctx := slog.With(ctx, - slog.F("dc_id", dc.ID()), - slog.F("dc_label", dc.Label()), - slog.F("dc_proto", dc.Protocol()), - ) - - dc.OnOpen(func() { - l.log.Info(ctx, "data channel opened") - rw, err := dc.Detach() - if err != nil { - return - } - - var init DialChannelResponse - sendInitMessage := func() { - l.log.Debug(ctx, "sending dc init message", slog.F("msg", init)) - initData, err := json.Marshal(&init) - if err != nil { - l.log.Debug(ctx, "failed to marshal dc init message", slog.Error(err)) - rw.Close() - return - } - _, err = rw.Write(initData) - if err != nil { - l.log.Debug(ctx, "failed to write dc init message", slog.Error(err)) - return - } - if init.Err != "" { - // If an error occurred, we're safe to close the connection. - l.log.Debug(ctx, "closing data channel due to error", slog.F("msg", init.Err)) - dc.Close() - return - } - } - - network, addr, err := msg.getAddress(dc.Protocol()) - if err != nil { - init.Code = CodeBadAddressErr - init.Err = err.Error() - var policyErr notPermittedByPolicyErr - if errors.As(err, &policyErr) { - init.Code = CodePermissionErr - } - sendInitMessage() - return - } - - l.log.Debug(ctx, "dialing remote address", slog.F("network", network), slog.F("addr", addr)) - nc, err := net.Dial(network, addr) - if err != nil { - l.log.Debug(ctx, "failed to dial remote address") - init.Code = CodeDialErr - init.Err = err.Error() - if op, ok := err.(*net.OpError); ok { - init.Net = op.Net - init.Op = op.Op - } - } - sendInitMessage() - if init.Err != "" { - return - } - - // Must wrap the data channel inside this connection - // for buffering from the dialed endpoint to the client. - l.log.Debug(ctx, "data channel initialized, tunnelling") - co := &dataChannelConn{ - addr: nil, - dc: dc, - rw: rw, - } - connClosersMut.Lock() - *connClosers = append(*connClosers, co) - connClosersMut.Unlock() - co.init() - defer nc.Close() - defer co.Close() - go func() { - defer dc.Close() - _, _ = io.Copy(co, nc) - }() - _, _ = io.Copy(nc, co) - }) - } -} - -// Close closes the broker socket and all created RTC connections. -func (l *listener) Close() error { - l.log.Info(context.Background(), "listener closed") - - l.connClosersMut.Lock() - defer l.connClosersMut.Unlock() - - select { - case _, ok := <-l.closed: - if !ok { - return errors.New("already closed") - } - default: - } - close(l.closed) - - for _, connCloser := range l.connClosers { - // We can ignore the error here... it doesn't - // really matter if these fail to close. - _ = connCloser.Close() - } - return l.ws.Close(websocket.StatusNormalClosure, "") -} - -// Since this listener is bound to the WebSocket, we could -// return that resolved Addr, but until we need it we won't. -func (l *listener) Addr() net.Addr { - return nil -} diff --git a/wsnet/listen_test.go b/wsnet/listen_test.go deleted file mode 100644 index 78b56691..00000000 --- a/wsnet/listen_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package wsnet - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest" - "github.com/stretchr/testify/require" - "nhooyr.io/websocket" -) - -func init() { - // We override this value to make tests faster. - connectionRetryInterval = 10 * time.Millisecond -} - -func TestListen(t *testing.T) { - t.Run("Reconnect", func(t *testing.T) { - var ( - connCh = make(chan *websocket.Conn) - mux = http.NewServeMux() - ) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - ws, err := websocket.Accept(w, r, nil) - if err != nil { - t.Error(err) - return - } - connCh <- ws - }) - - s := httptest.NewServer(mux) - defer s.Close() - - l, err := Listen(context.Background(), slogtest.Make(t, nil), s.URL, "") - require.NoError(t, err) - defer l.Close() - conn := <-connCh - - // Kill the server connection. - err = conn.Close(websocket.StatusGoingAway, "") - require.NoError(t, err) - - // At least a few retry attempts should be had... - time.Sleep(connectionRetryInterval * 5) - <-connCh - }) -} diff --git a/wsnet/proto.go b/wsnet/proto.go deleted file mode 100644 index feb4d126..00000000 --- a/wsnet/proto.go +++ /dev/null @@ -1,137 +0,0 @@ -package wsnet - -import ( - "fmt" - "math/bits" - "net" - "strconv" - "strings" - - "github.com/pion/webrtc/v3" -) - -// DialPolicy a single network + address + port combinations that a connection -// is permitted to use. -type DialPolicy struct { - // If network is empty, it applies to all networks. - Network string `json:"network"` - // Host is the IP or hostname of the address. It should not contain the - // port.If empty, it applies to all hosts. "localhost", [::1], and any IPv4 - // address under "127.0.0.0/8" can be used interchangeably. - Host string `json:"address"` - // If port is 0, it applies to all ports. - Port uint16 `json:"port"` -} - -// permits checks if a DialPolicy permits a specific network + host + port -// combination. The host must be put through normalizeHost first. -func (p DialPolicy) permits(network, host string, port uint16) bool { - if p.Network != "" && p.Network != network { - return false - } - if p.Host != "" && canonicalizeHost(p.Host) != host { - return false - } - if p.Port != 0 && p.Port != port { - return false - } - - return true -} - -// BrokerMessage is used for brokering a dialer and listener. -// -// Dialers initiate an exchange by providing an Offer, -// along with a list of ICE servers for the listener to -// peer with. -// -// The listener should respond with an offer, then both -// sides can begin exchanging candidates. -type BrokerMessage struct { - // Dialer -> Listener - Offer *webrtc.SessionDescription `json:"offer"` - Servers []webrtc.ICEServer `json:"servers"` - TURNProxyURL string `json:"turn_proxy_url"` - - // Policies denote which addresses the client can dial. If empty or nil, all - // addresses are permitted. - Policies []DialPolicy `json:"ports"` - - // Listener -> Dialer - Error string `json:"error"` - Answer *webrtc.SessionDescription `json:"answer"` - - // Bidirectional - Candidate string `json:"candidate"` -} - -// getAddress parses the data channel's protocol into an address suitable for -// net.Dial. It also verifies that the BrokerMessage permits connecting to said -// address. -func (msg BrokerMessage) getAddress(protocol string) (netwk, addr string, err error) { - parts := strings.SplitN(protocol, ":", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("invalid dial address: %v", protocol) - } - host, port, err := net.SplitHostPort(parts[1]) - if err != nil { - return "", "", fmt.Errorf("invalid dial address: %v", protocol) - } - - var ( - network = parts[0] - normalHost = canonicalizeHost(host) - // Still return the original host value, not the canonical value. - fullAddr = net.JoinHostPort(host, port) - ) - if network == "" { - return "", "", fmt.Errorf("invalid dial address %q network: %v", protocol, network) - } - if host == "" { - return "", "", fmt.Errorf("invalid dial address %q host: %v", protocol, host) - } - - portParsed, err := strconv.Atoi(port) - if err != nil || portParsed < 0 || bits.Len(uint(portParsed)) > 16 { - return "", "", fmt.Errorf("invalid dial address %q port: %v", protocol, port) - } - if len(msg.Policies) == 0 { - return network, fullAddr, nil - } - - portParsedU16 := uint16(portParsed) - for _, p := range msg.Policies { - if p.permits(network, normalHost, portParsedU16) { - return network, fullAddr, nil - } - } - - return "", "", fmt.Errorf("connections are not permitted to %q by policy", protocol) -} - -// canonicalizeHost converts all representations of "localhost" to "localhost". -func canonicalizeHost(addr string) string { - addr = strings.TrimPrefix(addr, "[") - addr = strings.TrimSuffix(addr, "]") - - ip := net.ParseIP(addr) - if ip == nil { - return addr - } - - if ip.IsLoopback() { - return "localhost" - } - return addr -} - -type notPermittedByPolicyErr struct { - protocol string -} - -var _ error = notPermittedByPolicyErr{} - -// Error implements error. -func (e notPermittedByPolicyErr) Error() string { - return fmt.Sprintf("connections are not permitted to %q by policy", e.protocol) -} diff --git a/wsnet/proto_test.go b/wsnet/proto_test.go deleted file mode 100644 index 89999f6b..00000000 --- a/wsnet/proto_test.go +++ /dev/null @@ -1,235 +0,0 @@ -package wsnet - -import ( - "fmt" - "testing" - - "cdr.dev/slog/sloggers/slogtest/assert" -) - -func Test_BrokerMessage(t *testing.T) { - t.Run("getAddress", func(t *testing.T) { - t.Run("OK", func(t *testing.T) { - var ( - msg = BrokerMessage{ - Policies: nil, - } - network = "tcp" - addr = "localhost:1234" - ) - - protocol := formatAddress(network, addr) - gotNetwork, gotAddr, err := msg.getAddress(protocol) - assert.Success(t, "got address", err) - assert.Equal(t, "networks equal", network, gotNetwork) - assert.Equal(t, "addresses equal", addr, gotAddr) - - msg.Policies = []DialPolicy{} - gotNetwork, gotAddr, err = msg.getAddress(protocol) - assert.Success(t, "got address", err) - assert.Equal(t, "networks equal", network, gotNetwork) - assert.Equal(t, "addresses equal", addr, gotAddr) - }) - - t.Run("InvalidProtocol", func(t *testing.T) { - cases := []struct { - protocol string - errContains string - }{ - { - protocol: "", - errContains: "invalid", - }, - { - protocol: "a:b", - errContains: "invalid", - }, - { - protocol: "a:b:c:d", - errContains: "invalid", - }, - { - protocol: ":localhost:1234", - errContains: "network", - }, - { - protocol: "tcp::1234", - errContains: "host", - }, - { - protocol: "tcp:localhost:", - errContains: "port", - }, - { - protocol: "tcp:localhost:asdf", - errContains: "port", - }, - { - protocol: "tcp:localhost:-1", - errContains: "port", - }, - { - // Overflow uint16. - protocol: fmt.Sprintf("tcp:localhost:%v", uint(1)<<16), - errContains: "port", - }, - } - - var msg BrokerMessage - for i, c := range cases { - amsg := fmt.Sprintf("case %v %q: ", i, c) - gotNetwork, gotAddr, err := msg.getAddress(c.protocol) - assert.Error(t, amsg+"successfully got invalid address", err) - assert.ErrorContains(t, fmt.Sprintf("%verr contains %q", amsg, c.errContains), err, c.errContains) - assert.Equal(t, amsg+"empty network", "", gotNetwork) - assert.Equal(t, amsg+"empty address", "", gotAddr) - } - }) - - t.Run("ChecksPolicies", func(t *testing.T) { - // ok == true tests automatically have a bunch of non-matching dial - // policies injected in front of them. - cases := []struct { - network string - host string - port uint16 - policy DialPolicy - ok bool - }{ - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "localhost", 1234), - ok: true, - }, - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("udp", "example.com", 51), - ok: false, - }, - // Network checks. - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("", "localhost", 1234), - ok: true, - }, - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("udp", "localhost", 1234), - ok: false, - }, - // Host checks. - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "", 1234), - ok: true, - }, - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "127.0.0.1", 1234), - ok: true, - }, - { - network: "tcp", - host: "127.0.0.1", - port: 1234, - policy: dialPolicy("tcp", "127.1.2.3", 1234), - ok: true, - }, - { - network: "tcp", - host: "[::1]", - port: 1234, - policy: dialPolicy("tcp", "127.1.2.3", 1234), - ok: true, - }, - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "example.com", 1234), - ok: false, - }, - { - network: "tcp", - host: "example.com", - port: 1234, - policy: dialPolicy("tcp", "localhost", 1234), - ok: false, - }, - // Port checks. - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "localhost", 5678), - ok: false, - }, - { - network: "tcp", - host: "localhost", - port: 1234, - policy: dialPolicy("tcp", "localhost", 0), - ok: true, - }, - } - - for i, c := range cases { - var ( - amsg = fmt.Sprintf("case %v '%+v': ", i, c) - msg = BrokerMessage{ - Policies: []DialPolicy{c.policy}, - } - ) - - // Add nonsense policies before the matching policy. - if c.ok { - msg.Policies = []DialPolicy{ - dialPolicy("asdf", "localhost", 1234), - dialPolicy("tcp", "asdf", 1234), - dialPolicy("tcp", "localhost", 17208), - c.policy, - } - } - - // Test DialPolicy. - assert.Equal(t, amsg+"policy matches", c.ok, c.policy.permits(c.network, canonicalizeHost(c.host), c.port)) - - // Test BrokerMessage. - protocol := formatAddress(c.network, fmt.Sprintf("%v:%v", c.host, c.port)) - gotNetwork, gotAddr, err := msg.getAddress(protocol) - if c.ok { - assert.Success(t, amsg, err) - } else { - assert.Error(t, amsg+"successfully got invalid address", err) - assert.ErrorContains(t, amsg+"err contains 'not permitted'", err, "not permitted") - assert.Equal(t, amsg+"empty network", "", gotNetwork) - assert.Equal(t, amsg+"empty address", "", gotAddr) - } - } - }) - }) -} - -func formatAddress(network, addr string) string { - return fmt.Sprintf("%v:%v", network, addr) -} - -func dialPolicy(network, host string, port uint16) DialPolicy { - return DialPolicy{ - Network: network, - Host: host, - Port: port, - } -} diff --git a/wsnet/rtc.go b/wsnet/rtc.go deleted file mode 100644 index 32a089a2..00000000 --- a/wsnet/rtc.go +++ /dev/null @@ -1,290 +0,0 @@ -package wsnet - -import ( - "context" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "strings" - "sync" - "time" - - "github.com/pion/dtls/v2" - "github.com/pion/ice/v2" - "github.com/pion/logging" - "github.com/pion/turn/v2" - "github.com/pion/webrtc/v3" - "golang.org/x/net/proxy" -) - -var ( - // ErrMismatchedProtocol occurs when a TURN is requested to a STUN server, - // or a TURN server is requested instead of TURNS. - ErrMismatchedProtocol = errors.New("mismatched protocols") - // ErrInvalidCredentials occurs when invalid credentials are passed to a - // TURN server. This error cannot occur for STUN servers, as they don't accept - // credentials. - ErrInvalidCredentials = errors.New("invalid credentials") - - // Constant for the control channel protocol. - controlChannel = "control" -) - -// DialICEOptions provides options for dialing an ICE server. -type DialICEOptions struct { - Timeout time.Duration - // Whether to ignore TLS errors. - InsecureSkipVerify bool -} - -// DialICE confirms ICE servers are dialable. -// Timeout defaults to 200ms. -func DialICE(server webrtc.ICEServer, options *DialICEOptions) error { - if options == nil { - options = &DialICEOptions{} - } - - for _, rawURL := range server.URLs { - err := dialICEURL(server, rawURL, options) - if err != nil { - return err - } - } - return nil -} - -func dialICEURL(server webrtc.ICEServer, rawURL string, options *DialICEOptions) error { - url, err := ice.ParseURL(rawURL) - if err != nil { - return err - } - var ( - tcpConn net.Conn - udpConn net.PacketConn - turnServerAddr = fmt.Sprintf("%s:%d", url.Host, url.Port) - ) - switch { - case url.Scheme == ice.SchemeTypeTURN || url.Scheme == ice.SchemeTypeSTUN: - switch url.Proto { - case ice.ProtoTypeUDP: - udpConn, err = net.ListenPacket("udp4", "0.0.0.0:0") - case ice.ProtoTypeTCP: - tcpConn, err = net.Dial("tcp4", turnServerAddr) - } - case url.Scheme == ice.SchemeTypeTURNS || url.Scheme == ice.SchemeTypeSTUNS: - switch url.Proto { - case ice.ProtoTypeUDP: - udpAddr, resErr := net.ResolveUDPAddr("udp4", turnServerAddr) - if resErr != nil { - return resErr - } - dconn, dialErr := dtls.Dial("udp4", udpAddr, &dtls.Config{ - InsecureSkipVerify: options.InsecureSkipVerify, - }) - err = dialErr - udpConn = turn.NewSTUNConn(dconn) - case ice.ProtoTypeTCP: - tcpConn, err = tls.Dial("tcp4", turnServerAddr, &tls.Config{ - InsecureSkipVerify: options.InsecureSkipVerify, - }) - } - } - - if err != nil { - return err - } - if tcpConn != nil { - udpConn = turn.NewSTUNConn(tcpConn) - } - defer udpConn.Close() - - var pass string - if server.Credential != nil && server.CredentialType == webrtc.ICECredentialTypePassword { - pass = server.Credential.(string) - } - - client, err := turn.NewClient(&turn.ClientConfig{ - STUNServerAddr: turnServerAddr, - TURNServerAddr: turnServerAddr, - Username: server.Username, - Password: pass, - Realm: "", - Conn: udpConn, - RTO: options.Timeout, - }) - if err != nil { - return err - } - defer client.Close() - err = client.Listen() - if err != nil { - return err - } - // STUN servers are not authenticated with credentials. - // As long as the transport is valid, this should always work. - _, err = client.SendBindingRequest() - if err != nil { - // Transport failed to connect. - // https://github.com/pion/turn/blob/8231b69046f562420299916e9fb69cbff4754231/errors.go#L20 - if strings.Contains(err.Error(), "retransmissions failed") { - return ErrMismatchedProtocol - } - return fmt.Errorf("binding: %w", err) - } - if url.Scheme == ice.SchemeTypeTURN || url.Scheme == ice.SchemeTypeTURNS { - // We TURN to validate server credentials are correct. - pc, err := client.Allocate() - if err != nil { - if strings.Contains(err.Error(), "error 400") { - return ErrInvalidCredentials - } - // Since TURN and STUN follow the same protocol, they can - // both handshake, but once a tunnel is allocated it will - // fail to transmit. - if strings.Contains(err.Error(), "retransmissions failed") { - return ErrMismatchedProtocol - } - return err - } - defer pc.Close() - } - return nil -} - -// Generalizes creating a new peer connection with consistent options. -func newPeerConnection(servers []webrtc.ICEServer, dialer proxy.Dialer) (*webrtc.PeerConnection, error) { - se := webrtc.SettingEngine{} - se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeUDP4}) - se.SetSrflxAcceptanceMinWait(0) - se.DetachDataChannels() - // If the disconnect and keep-alive timeouts are too closely related, we'll - // experience "random" connection failures. - se.SetICETimeouts(time.Second*5, time.Second*25, time.Second*2) - lf := logging.NewDefaultLoggerFactory() - lf.DefaultLogLevel = logging.LogLevelDisabled - se.LoggerFactory = lf - - // Enables tunneling of TURN traffic through an arbitrary proxy. - // We proxy TURN over a WebSocket to reduce deployment complexity. - if dialer != nil { - se.SetICEProxyDialer(dialer) - } - - transportPolicy := webrtc.ICETransportPolicyAll - - // If one server is provided and we know it's TURN, we can set the - // relay acceptable so the connection starts immediately. - if len(servers) == 1 { - server := servers[0] - if len(server.URLs) == 1 { - url, err := ice.ParseURL(server.URLs[0]) - if err == nil && server.Credential != nil && url.Proto == ice.ProtoTypeTCP { - se.SetNetworkTypes([]webrtc.NetworkType{webrtc.NetworkTypeTCP4, webrtc.NetworkTypeTCP6}) - se.SetRelayAcceptanceMinWait(0) - } - if err == nil && (url.Scheme == ice.SchemeTypeTURN || url.Scheme == ice.SchemeTypeTURNS) { - // Local peers will connect if they discover they live on the same host. - // For testing purposes, it's simpler if they cannot peer on the same host. - transportPolicy = webrtc.ICETransportPolicyRelay - } - } - } - api := webrtc.NewAPI(webrtc.WithSettingEngine(se)) - - return api.NewPeerConnection(webrtc.Configuration{ - ICEServers: servers, - ICETransportPolicy: transportPolicy, - }) -} - -// Proxies ICE candidates using the protocol to a writer. -func proxyICECandidates(conn *webrtc.PeerConnection, w io.Writer) func() { - var ( - mut sync.Mutex - queue = []*webrtc.ICECandidate{} - flushed = false - write = func(i *webrtc.ICECandidate) { - b, _ := json.Marshal(&BrokerMessage{ - Candidate: i.ToJSON().Candidate, - }) - _, _ = w.Write(b) - } - ) - - conn.OnICECandidate(func(i *webrtc.ICECandidate) { - if i == nil { - return - } - mut.Lock() - defer mut.Unlock() - if !flushed { - queue = append(queue, i) - return - } - - write(i) - }) - return func() { - mut.Lock() - defer mut.Unlock() - for _, i := range queue { - write(i) - } - flushed = true - } -} - -// Waits for a PeerConnection to hit the open state. -func waitForConnectionOpen(ctx context.Context, conn *webrtc.PeerConnection) error { - if conn.ConnectionState() == webrtc.PeerConnectionStateConnected { - return nil - } - var cancel context.CancelFunc - if _, deadlineSet := ctx.Deadline(); deadlineSet { - ctx, cancel = context.WithCancel(ctx) - } else { - ctx, cancel = context.WithTimeout(ctx, time.Second*15) - } - defer cancel() - conn.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) { - if pcs == webrtc.PeerConnectionStateConnected { - cancel() - } - }) - <-ctx.Done() - if ctx.Err() == context.DeadlineExceeded { - return context.DeadlineExceeded - } - return nil -} - -// Waits for a DataChannel to hit the open state. -func waitForDataChannelOpen(ctx context.Context, channel *webrtc.DataChannel) error { - if channel.ReadyState() == webrtc.DataChannelStateOpen { - return nil - } - if channel.ReadyState() != webrtc.DataChannelStateConnecting { - return fmt.Errorf("channel closed") - } - ctx, cancelFunc := context.WithTimeout(ctx, time.Second*15) - defer cancelFunc() - channel.OnOpen(func() { - cancelFunc() - }) - <-ctx.Done() - if ctx.Err() == context.DeadlineExceeded { - return ctx.Err() - } - return nil -} - -func stringPtr(s string) *string { - return &s -} - -func boolPtr(b bool) *bool { - return &b -} diff --git a/wsnet/rtc_test.go b/wsnet/rtc_test.go deleted file mode 100644 index 73d1af2f..00000000 --- a/wsnet/rtc_test.go +++ /dev/null @@ -1,83 +0,0 @@ -package wsnet - -import ( - "errors" - "fmt" - "testing" - "time" - - "github.com/pion/ice/v2" - "github.com/pion/webrtc/v3" -) - -func TestDialICE(t *testing.T) { - t.Parallel() - - t.Run("TURN with TLS", func(t *testing.T) { - t.Parallel() - - addr, _ := createTURNServer(t, ice.SchemeTypeTURNS) - err := DialICE(webrtc.ICEServer{ - URLs: []string{fmt.Sprintf("turns:%s", addr)}, - Username: "example", - Credential: testPass, - CredentialType: webrtc.ICECredentialTypePassword, - }, &DialICEOptions{ - Timeout: time.Millisecond, - InsecureSkipVerify: true, - }) - if err != nil { - t.Error(err) - } - }) - - t.Run("Protocol mismatch", func(t *testing.T) { - t.Parallel() - - addr, _ := createTURNServer(t, ice.SchemeTypeTURNS) - err := DialICE(webrtc.ICEServer{ - URLs: []string{fmt.Sprintf("turn:%s", addr)}, - Username: "example", - Credential: testPass, - CredentialType: webrtc.ICECredentialTypePassword, - }, &DialICEOptions{ - Timeout: time.Millisecond, - InsecureSkipVerify: true, - }) - if !errors.Is(err, ErrMismatchedProtocol) { - t.Error(err) - } - }) - - t.Run("Invalid auth", func(t *testing.T) { - t.Parallel() - - addr, _ := createTURNServer(t, ice.SchemeTypeTURNS) - err := DialICE(webrtc.ICEServer{ - URLs: []string{fmt.Sprintf("turns:%s", addr)}, - Username: "example", - Credential: "invalid", - CredentialType: webrtc.ICECredentialTypePassword, - }, &DialICEOptions{ - Timeout: time.Millisecond, - InsecureSkipVerify: true, - }) - if !errors.Is(err, ErrInvalidCredentials) { - t.Error(err) - } - }) - - t.Run("Protocol mismatch public", func(t *testing.T) { - t.Parallel() - - err := DialICE(webrtc.ICEServer{ - URLs: []string{"turn:stun.l.google.com:19302"}, - }, &DialICEOptions{ - Timeout: time.Millisecond, - InsecureSkipVerify: true, - }) - if !errors.Is(err, ErrMismatchedProtocol) { - t.Error(err) - } - }) -} diff --git a/wsnet/wsnet_test.go b/wsnet/wsnet_test.go deleted file mode 100644 index 20aa7699..00000000 --- a/wsnet/wsnet_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package wsnet - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "fmt" - "io" - "math/big" - "net" - "net/http" - "sync" - "testing" - "time" - - "cdr.dev/slog/sloggers/slogtest/assert" - "github.com/hashicorp/yamux" - "github.com/pion/ice/v2" - "github.com/pion/logging" - "github.com/pion/turn/v2" - "nhooyr.io/websocket" -) - -const ( - // Password used connecting to the test TURN server. - testPass = "test" -) - -// createDumbBroker proxies sockets between /listen and /connect -// to emulate an authenticated WebSocket pair. -func createDumbBroker(t testing.TB) (connectAddr string, listenAddr string) { - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - t.Error(err) - } - t.Cleanup(func() { - listener.Close() - }) - var ( - mux = http.NewServeMux() - sess *yamux.Session - mut sync.Mutex - ) - mux.HandleFunc("/listen", func(w http.ResponseWriter, r *http.Request) { - c, err := websocket.Accept(w, r, nil) - if err != nil { - t.Error(err) - } - nc := websocket.NetConn(context.Background(), c, websocket.MessageBinary) - mut.Lock() - defer mut.Unlock() - sess, err = yamux.Client(nc, nil) - if err != nil { - t.Error(err) - } - }) - mux.HandleFunc("/connect", func(w http.ResponseWriter, r *http.Request) { - c, err := websocket.Accept(w, r, nil) - if err != nil { - t.Error(err) - return - } - nc := websocket.NetConn(context.Background(), c, websocket.MessageBinary) - mut.Lock() - defer mut.Unlock() - if sess == nil { - // We discard inbound to emulate a pubsub where we don't know if anyone - // is listening on the other side. - _, _ = io.Copy(io.Discard, nc) - return - } - oc, err := sess.Open() - if err != nil { - t.Error(err) - } - go func() { - _, _ = io.Copy(nc, oc) - }() - _, _ = io.Copy(oc, nc) - }) - - s := http.Server{ - Handler: mux, - } - go func() { - _ = s.Serve(listener) - }() - return fmt.Sprintf("ws://%s/connect", listener.Addr()), fmt.Sprintf("ws://%s/listen", listener.Addr()) -} - -// createTURNServer allocates a TURN server and returns the address. -func createTURNServer(t *testing.T, server ice.SchemeType) (string, func()) { - var ( - listeners []turn.ListenerConfig - pcListeners []turn.PacketConnConfig - relay = &turn.RelayAddressGeneratorStatic{ - RelayAddress: net.ParseIP("127.0.0.1"), - Address: "127.0.0.1", - } - listenAddr net.Addr - ) - url, _ := ice.ParseURL(fmt.Sprintf("%s:localhost", server)) - - switch url.Proto { - case ice.ProtoTypeTCP: - var ( - tcpListener net.Listener - err error - ) - if url.IsSecure() { - tcpListener, err = tls.Listen("tcp4", "127.0.0.1:0", generateTLSConfig(t)) - } else { - tcpListener, err = net.Listen("tcp4", "127.0.0.1:0") - } - if err != nil { - t.Error(err) - } - listenAddr = tcpListener.Addr() - listeners = []turn.ListenerConfig{{ - Listener: tcpListener, - RelayAddressGenerator: relay, - }} - case ice.ProtoTypeUDP: - udpListener, err := net.ListenPacket("udp4", "127.0.0.1:0") - if err != nil { - t.Error(err) - } - listenAddr = udpListener.LocalAddr() - pcListeners = []turn.PacketConnConfig{{ - PacketConn: udpListener, - RelayAddressGenerator: relay, - }} - } - - lf := logging.NewDefaultLoggerFactory() - lf.DefaultLogLevel = logging.LogLevelDisabled - srv, err := turn.NewServer(turn.ServerConfig{ - PacketConnConfigs: pcListeners, - ListenerConfigs: listeners, - Realm: "coder", - AuthHandler: func(username, realm string, srcAddr net.Addr) (key []byte, ok bool) { - return turn.GenerateAuthKey(username, realm, testPass), true - }, - LoggerFactory: lf, - }) - if err != nil { - t.Error(err) - } - closeFunc := func() { - for _, l := range listeners { - l.Listener.Close() - } - for _, l := range pcListeners { - l.PacketConn.Close() - } - srv.Close() - } - t.Cleanup(closeFunc) - - return listenAddr.String(), closeFunc -} - -func generateTLSConfig(t testing.TB) *tls.Config { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - assert.Success(t, "generate key", err) - template := x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - Organization: []string{"Acme Co"}, - }, - NotBefore: time.Now(), - NotAfter: time.Now().Add(time.Hour * 24 * 180), - - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - } - derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) - assert.Success(t, "create certificate", err) - certBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey) - assert.Success(t, "marshal private key", err) - keyBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyBytes}) - cert, err := tls.X509KeyPair(certBytes, keyBytes) - assert.Success(t, "convert to key pair", err) - return &tls.Config{ - Certificates: []tls.Certificate{cert}, - InsecureSkipVerify: true, - } -} pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy